1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2024-11-26 01:42:53 +01:00

Compare commits

..

2 Commits

Author SHA1 Message Date
0df1546fb6
WIP 2023-10-27 16:54:12 +02:00
d752a482b2
WIP 2023-10-23 23:14:55 +02:00
44 changed files with 1552 additions and 1657 deletions

View File

@ -18,4 +18,8 @@ public sealed class ControllerConnection {
public Task Send<TMessage>(TMessage message) where TMessage : IMessageToController { public Task Send<TMessage>(TMessage message) where TMessage : IMessageToController {
return connection.Send(message); return connection.Send(message);
} }
public Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToController<TReply> where TReply : class {
return connection.Send<TMessage, TReply>(message, waitForReplyTime, waitForReplyCancellationToken);
}
} }

View File

@ -1,18 +1,14 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using MemoryPack;
namespace Phantom.Common.Data.Web.Users; namespace Phantom.Common.Data.Web.Users;
[MemoryPackable(GenerateType.VersionTolerant)] public sealed class IdentityPermissions {
public sealed partial class PermissionSet { public static IdentityPermissions None { get; } = new (ImmutableHashSet<string>.Empty);
public static PermissionSet None { get; } = new (ImmutableHashSet<string>.Empty);
[MemoryPackOrder(0)]
[MemoryPackInclude]
private readonly ImmutableHashSet<string> permissionIds; private readonly ImmutableHashSet<string> permissionIds;
public PermissionSet(ImmutableHashSet<string> permissionIds) { public IdentityPermissions(ImmutableHashSet<string> permissionIdsQuery) {
this.permissionIds = permissionIds; this.permissionIds = permissionIdsQuery;
} }
public bool Check(Permission? permission) { public bool Check(Permission? permission) {

View File

@ -1,11 +0,0 @@
using System.Collections.Immutable;
using MemoryPack;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record LogInSuccess (
[property: MemoryPackOrder(0)] Guid UserGuid,
[property: MemoryPackOrder(1)] PermissionSet Permissions,
[property: MemoryPackOrder(2)] ImmutableArray<byte> Token
);

View File

@ -8,6 +8,6 @@ namespace Phantom.Common.Messages.Web;
public interface IMessageToControllerListener { public interface IMessageToControllerListener {
Task<NoReply> HandleRegisterWeb(RegisterWebMessage message); Task<NoReply> HandleRegisterWeb(RegisterWebMessage message);
Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message); Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message);
Task<LogInSuccess?> HandleLogIn(LogIn message); Task<byte[]?> HandleLogIn(LogIn message);
Task<NoReply> HandleReply(ReplyMessage message); Task<NoReply> HandleReply(ReplyMessage message);
} }

View File

@ -1,5 +1,4 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Web.Users;
namespace Phantom.Common.Messages.Web.ToController; namespace Phantom.Common.Messages.Web.ToController;
@ -7,8 +6,8 @@ namespace Phantom.Common.Messages.Web.ToController;
public sealed partial record LogIn( public sealed partial record LogIn(
[property: MemoryPackOrder(0)] string Username, [property: MemoryPackOrder(0)] string Username,
[property: MemoryPackOrder(1)] string Password [property: MemoryPackOrder(1)] string Password
) : IMessageToController<LogInSuccess?> { ) : IMessageToController<byte[]?> {
public Task<LogInSuccess?> Accept(IMessageToControllerListener listener) { public Task<byte[]?> Accept(IMessageToControllerListener listener) {
return listener.HandleLogIn(this); return listener.HandleLogIn(this);
} }
}; };

View File

@ -16,7 +16,6 @@ public static class WebMessageRegistries {
static WebMessageRegistries() { static WebMessageRegistries() {
ToController.Add<RegisterWebMessage>(0); ToController.Add<RegisterWebMessage>(0);
ToController.Add<CreateOrUpdateAdministratorUser, CreateOrUpdateAdministratorUserResult>(1); ToController.Add<CreateOrUpdateAdministratorUser, CreateOrUpdateAdministratorUserResult>(1);
ToController.Add<LogIn, LogInSuccess?>(2);
ToController.Add<ReplyMessage>(127); ToController.Add<ReplyMessage>(127);
ToWeb.Add<RegisterWebResultMessage>(0); ToWeb.Add<RegisterWebResultMessage>(0);

View File

@ -66,7 +66,7 @@ public sealed class RpcConnectionToClient<TListener> {
} }
await socket.SendAsync(routingId, bytes); await socket.SendAsync(routingId, bytes);
return await messageReplyTracker.TryWaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken); return await messageReplyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
} }
public void Receive(IReply message) { public void Receive(IReply message) {

View File

@ -48,7 +48,7 @@ public sealed class ControllerServices {
this.RoleManager = new RoleManager(dbProvider); this.RoleManager = new RoleManager(dbProvider);
this.PermissionManager = new PermissionManager(dbProvider); this.PermissionManager = new PermissionManager(dbProvider);
this.UserLoginManager = new UserLoginManager(UserManager, PermissionManager); this.UserLoginManager = new UserLoginManager(UserManager);
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
this.webAuthToken = webAuthToken; this.webAuthToken = webAuthToken;

View File

@ -45,7 +45,7 @@ public sealed class WebMessageListener : IMessageToControllerListener {
return userManager.CreateOrUpdateAdministrator(message.Username, message.Password); return userManager.CreateOrUpdateAdministrator(message.Username, message.Password);
} }
public Task<LogInSuccess?> HandleLogIn(LogIn message) { public Task<byte[]?> HandleLogIn(LogIn message) {
return userLoginManager.LogIn(message.Username, message.Password); return userLoginManager.LogIn(message.Username, message.Password);
} }

View File

@ -9,16 +9,17 @@ using Serilog;
namespace Phantom.Controller.Services.Users; namespace Phantom.Controller.Services.Users;
sealed class PermissionManager { public sealed class PermissionManager {
private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>(); private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>();
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new ();
public PermissionManager(IDbContextProvider dbProvider) { public PermissionManager(IDbContextProvider dbProvider) {
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
} }
public async Task Initialize() { internal async Task Initialize() {
Logger.Information("Adding default permissions to database."); Logger.Information("Adding default permissions to database.");
await using var ctx = dbProvider.Eager(); await using var ctx = dbProvider.Eager();
@ -36,21 +37,32 @@ sealed class PermissionManager {
} }
} }
public async Task<PermissionSet> FetchPermissionsForUserId(Guid userId) { internal static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
await using var ctx = dbProvider.Eager();
var userPermissions = ctx.UserPermissions
.Where(up => up.UserGuid == userId)
.Select(static up => up.PermissionId);
var rolePermissions = ctx.UserRoles
.Where(ur => ur.UserGuid == userId)
.Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
return new PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
}
public static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray(); return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
} }
private async Task<IdentityPermissions> FetchPermissionsForUserId(Guid userId) {
await using var ctx = dbProvider.Eager();
var userPermissions = ctx.UserPermissions.Where(up => up.UserGuid == userId).Select(static up => up.PermissionId);
var rolePermissions = ctx.UserRoles.Where(ur => ur.UserGuid == userId).Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
return new IdentityPermissions(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
}
// private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) {
// if (!refreshCache && userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) {
// return userPermissions;
// }
// else {
// return userIdsToPermissionIds[userId] = FetchPermissionsForUserId(userId);
// }
// }
//
// public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) {
// Guid? userId = UserManager.GetAuthenticatedUserId(user);
// return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId.Value, refreshCache);
// }
//
// public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {
// return GetPermissions(user, refreshCache).Check(permission);
// }
} }

View File

@ -1,34 +1,30 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Security.Cryptography; using System.Security.Cryptography;
using Phantom.Common.Data.Web.Users;
namespace Phantom.Controller.Services.Users; namespace Phantom.Controller.Services.Users;
sealed class UserLoginManager { sealed class UserLoginManager {
private const int SessionIdBytes = 20; private const int SessionIdBytes = 20;
private readonly ConcurrentDictionary<string, List<ImmutableArray<byte>>> sessionTokensByUsername = new (); private readonly ConcurrentDictionary<string, List<byte[]>> sessionTokensByUsername = new ();
private readonly UserManager userManager; private readonly UserManager userManager;
private readonly PermissionManager permissionManager;
public UserLoginManager(UserManager userManager, PermissionManager permissionManager) { public UserLoginManager(UserManager userManager) {
this.userManager = userManager; this.userManager = userManager;
this.permissionManager = permissionManager;
} }
public async Task<LogInSuccess?> LogIn(string username, string password) { public async Task<byte[]?> LogIn(string username, string password) {
var user = await userManager.GetAuthenticated(username, password); var user = await userManager.GetAuthenticated(username, password);
if (user == null) { if (user == null) {
return null; return null;
} }
var token = ImmutableArray.Create(RandomNumberGenerator.GetBytes(SessionIdBytes)); var token = RandomNumberGenerator.GetBytes(SessionIdBytes);
var sessionTokens = sessionTokensByUsername.GetOrAdd(username, static _ => new List<ImmutableArray<byte>>()); var sessionTokens = sessionTokensByUsername.GetOrAdd(username, static _ => new List<byte[]>());
lock (sessionTokens) { lock (sessionTokens) {
sessionTokens.Add(token); sessionTokens.Add(token);
} }
return new LogInSuccess(user.UserGuid, await permissionManager.FetchPermissionsForUserId(user.UserGuid), token); return token;
} }
} }

View File

@ -20,37 +20,27 @@ public sealed class MessageReplyTracker {
return sequenceId; return sequenceId;
} }
public async Task<TReply> WaitForReply<TReply>(uint sequenceId, TimeSpan waitForReplyTime, CancellationToken cancellationToken) { public async Task<TReply?> WaitForReply<TReply>(uint sequenceId, TimeSpan waitForReplyTime, CancellationToken cancellationToken) where TReply : class {
if (!replyTasks.TryGetValue(sequenceId, out var completionSource)) { if (!replyTasks.TryGetValue(sequenceId, out var completionSource)) {
logger.Warning("No reply callback for id {SequenceId}.", sequenceId); logger.Warning("No reply callback for id {SequenceId}.", sequenceId);
throw new ArgumentException("No reply callback for id: " + sequenceId, nameof(sequenceId)); return null;
} }
try { try {
byte[] replyBytes = await completionSource.Task.WaitAsync(waitForReplyTime, cancellationToken); byte[] replyBytes = await completionSource.Task.WaitAsync(waitForReplyTime, cancellationToken);
return MessageSerializer.Deserialize<TReply>(replyBytes); return MessageSerializer.Deserialize<TReply>(replyBytes);
} catch (TimeoutException) { } catch (TimeoutException) {
logger.Debug("Timed out waiting for reply with id {SequenceId}.", sequenceId); return null;
throw;
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
logger.Debug("Cancelled waiting for reply with id {SequenceId}.", sequenceId); return null;
throw;
} catch (Exception e) { } catch (Exception e) {
logger.Warning(e, "Error processing reply with id {SequenceId}.", sequenceId); logger.Warning(e, "Error processing reply with id {SequenceId}.", sequenceId);
throw; return null;
} finally { } finally {
ForgetReply(sequenceId); ForgetReply(sequenceId);
} }
} }
public async Task<TReply?> TryWaitForReply<TReply>(uint sequenceId, TimeSpan waitForReplyTime, CancellationToken cancellationToken) where TReply : class {
try {
return await WaitForReply<TReply>(sequenceId, waitForReplyTime, cancellationToken);
} catch (Exception) {
return null;
}
}
public void ForgetReply(uint sequenceId) { public void ForgetReply(uint sequenceId) {
if (replyTasks.TryRemove(sequenceId, out var task)) { if (replyTasks.TryRemove(sequenceId, out var task)) {
task.SetCanceled(); task.SetCanceled();

View File

@ -22,7 +22,7 @@ public sealed class RpcConnectionToServer<TListener> {
} }
} }
public async Task<TReply?> TrySend<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class { public async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class {
var sequenceId = replyTracker.RegisterReply(); var sequenceId = replyTracker.RegisterReply();
var bytes = messageRegistry.Write<TMessage, TReply>(sequenceId, message).ToArray(); var bytes = messageRegistry.Write<TMessage, TReply>(sequenceId, message).ToArray();
@ -31,19 +31,6 @@ public sealed class RpcConnectionToServer<TListener> {
return null; return null;
} }
await socket.SendAsync(bytes);
return await replyTracker.TryWaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
}
public async Task<TReply> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> {
var sequenceId = replyTracker.RegisterReply();
var bytes = messageRegistry.Write<TMessage, TReply>(sequenceId, message).ToArray();
if (bytes.Length == 0) {
replyTracker.ForgetReply(sequenceId);
throw new ArgumentException("Could not write message.", nameof(message));
}
await socket.SendAsync(bytes); await socket.SendAsync(bytes);
return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken); return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
} }

View File

@ -1,90 +0,0 @@
using System.Collections.Immutable;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using Phantom.Common.Logging;
using ILogger = Serilog.ILogger;
namespace Phantom.Web.Services.Authentication;
public sealed class PhantomAuthenticationStateProvider : ServerAuthenticationStateProvider {
private const string SessionTokenKey = "PhantomSession";
private static readonly ILogger Logger = PhantomLogger.Create<PhantomAuthenticationStateProvider>();
private readonly PhantomLoginSessions loginSessions;
private readonly ProtectedLocalStorage localStorage;
private bool isLoaded;
public PhantomAuthenticationStateProvider(PhantomLoginSessions loginSessions, ProtectedLocalStorage localStorage) {
this.loginSessions = loginSessions;
this.localStorage = localStorage;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
if (isLoaded) {
return await base.GetAuthenticationStateAsync();
}
LocalStorageEntry? stored;
try {
stored = await GetLocalStorageEntry();
} catch (InvalidOperationException) {
stored = null;
}
if (stored != null) {
var session = loginSessions.Find(stored.UserGuid, stored.Token);
if (session != null) {
SetLoadedSession(session);
}
}
return await base.GetAuthenticationStateAsync();
}
private sealed record LocalStorageEntry(Guid UserGuid, ImmutableArray<byte> Token);
private async Task<LocalStorageEntry?> GetLocalStorageEntry() {
try {
var result = await localStorage.GetAsync<LocalStorageEntry>(SessionTokenKey);
return result.Success ? result.Value : null;
} catch (InvalidOperationException) {
return null;
} catch (CryptographicException) {
return null;
} catch (Exception e) {
Logger.Error(e, "Could not read local storage entry.");
return null;
}
}
private void SetLoadedSession(UserSession session) {
isLoaded = true;
SetAuthenticationState(Task.FromResult(new AuthenticationState(session.AsClaimsPrincipal)));
}
internal async Task HandleLogin(UserSession session) {
await localStorage.SetAsync(SessionTokenKey, new LocalStorageEntry(session.UserGuid, session.Token));
loginSessions.Add(session);
SetLoadedSession(session);
}
internal async Task HandleLogout() {
if (!isLoaded) {
return;
}
await localStorage.DeleteAsync(SessionTokenKey);
var stored = await GetLocalStorageEntry();
if (stored != null) {
loginSessions.Remove(stored.UserGuid, stored.Token);
}
isLoaded = false;
SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal())));
}
}

View File

@ -0,0 +1,37 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Authentication;
namespace Phantom.Web.Services.Authentication;
sealed class PhantomIdentityMiddleware {
public const string LoginPath = "/login";
public const string LogoutPath = "/logout";
public static bool AcceptsPath(HttpContext context) {
var path = context.Request.Path;
return path == LoginPath || path == LogoutPath;
}
private readonly RequestDelegate next;
public PhantomIdentityMiddleware(RequestDelegate next) {
this.next = next;
}
[SuppressMessage("ReSharper", "UnusedMember.Global")]
public async Task InvokeAsync(HttpContext context, INavigation navigation, PhantomLoginManager loginManager) {
var path = context.Request.Path;
if (path == LoginPath && context.Request.Query.TryGetValue("token", out var tokens) && tokens[0] is {} token && await loginManager.ProcessToken(token) is {} result) {
await context.SignInAsync(result.ClaimsPrincipal, result.AuthenticationProperties);
context.Response.Redirect(navigation.BasePath + result.ReturnUrl);
}
else if (path == LogoutPath) {
loginManager.OnSignedOut(context.User);
await context.SignOutAsync();
context.Response.Redirect(navigation.BasePath);
}
else {
await next.Invoke(context);
}
}
}

View File

@ -1,8 +1,8 @@
using System.Security.Claims; using System.Security.Claims;
using Phantom.Common.Data.Web.Users; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Messages.Web.ToController; using Phantom.Utils.Cryptography;
using Phantom.Web.Services.Rpc;
using ILogger = Serilog.ILogger; using ILogger = Serilog.ILogger;
namespace Phantom.Web.Services.Authentication; namespace Phantom.Web.Services.Authentication;
@ -10,44 +10,66 @@ namespace Phantom.Web.Services.Authentication;
public sealed class PhantomLoginManager { public sealed class PhantomLoginManager {
private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginManager>(); private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginManager>();
public const string AuthenticationType = "Phantom"; public static bool IsAuthenticated(ClaimsPrincipal user) {
return user.Identity is { IsAuthenticated: true };
}
private readonly INavigation navigation; private readonly INavigation navigation;
private readonly PhantomAuthenticationStateProvider authenticationStateProvider; private readonly PhantomLoginStore loginStore;
private readonly ControllerConnection controllerConnection; private readonly ProtectedSessionStorage sessionStorage;
public PhantomLoginManager(INavigation navigation, PhantomAuthenticationStateProvider authenticationStateProvider, ControllerConnection controllerConnection) { public PhantomLoginManager(INavigation navigation, PhantomLoginStore loginStore, ProtectedSessionStorage sessionStorage) {
this.navigation = navigation; this.navigation = navigation;
this.authenticationStateProvider = authenticationStateProvider; this.loginStore = loginStore;
this.controllerConnection = controllerConnection; this.sessionStorage = sessionStorage;
} }
public async Task<bool> SignIn(string username, string password, string? returnUrl = null) { public async Task<bool> SignIn(string username, string password, string? returnUrl = null) {
LogInSuccess? success; return false;
try { // if (await userManager.GetAuthenticated(username, password) == null) {
success = await controllerConnection.Send<LogIn, LogInSuccess?>(new LogIn(username, password), TimeSpan.FromSeconds(30)); // return false;
} catch (Exception e) { // }
Logger.Error(e, "Could not log in {Username}.", username);
return false;
}
if (success == null) { Logger.Debug("Created login token for {Username}.", username);
return false;
}
Logger.Information("Successfully logged in {Username}.", username); string token = TokenGenerator.Create(60);
loginStore.Add(token, username, password, returnUrl ?? string.Empty);
navigation.NavigateTo("login" + QueryString.Create("token", token), forceLoad: true);
var identity = new ClaimsIdentity(AuthenticationType);
identity.AddClaim(new Claim(ClaimTypes.Name, username));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, success.UserGuid.ToString()));
await authenticationStateProvider.HandleLogin(new UserSession(success.UserGuid, username, success.Token));
await navigation.NavigateTo(returnUrl ?? string.Empty);
return true; return true;
} }
public async Task SignOut() { internal async Task<SignInResult?> ProcessToken(string token) {
await navigation.NavigateTo(string.Empty); return null;
await authenticationStateProvider.HandleLogout(); // var entry = loginStore.Pop(token);
// if (entry == null) {
// return null;
// }
//
// var user = await userManager.GetAuthenticated(entry.Username, entry.Password);
// if (user == null) {
// return null;
// }
//
// Logger.Information("Successful login for {Username}.", user.Name);
// loginEvents.UserLoggedIn(user);
//
// var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
// identity.AddClaim(new Claim(ClaimTypes.Name, user.Name));
// identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.UserGuid.ToString()));
//
// var authenticationProperties = new AuthenticationProperties {
// IsPersistent = true
// };
//
// return new SignInResult(new ClaimsPrincipal(identity), authenticationProperties, entry.ReturnUrl);
}
internal sealed record SignInResult(ClaimsPrincipal ClaimsPrincipal, AuthenticationProperties AuthenticationProperties, string ReturnUrl);
internal void OnSignedOut(ClaimsPrincipal user) {
// if (UserManager.GetAuthenticatedUserId(user) is {} userGuid) {
// loginEvents.UserLoggedOut(userGuid);
// }
} }
} }

View File

@ -1,49 +0,0 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace Phantom.Web.Services.Authentication;
public sealed class PhantomLoginSessions {
private readonly ConcurrentDictionary<Guid, List<UserSession>> userSessions = new ();
internal void Add(UserSession session) {
var sessions = userSessions.GetOrAdd(session.UserGuid, static _ => new List<UserSession>());
lock (sessions) {
RemoveSessionInternal(sessions, session.Token);
sessions.Add(session);
}
}
internal UserSession? Find(Guid userGuid, ImmutableArray<byte> token) {
if (!userSessions.TryGetValue(userGuid, out var sessions)) {
return null;
}
lock (sessions) {
int index = FindSessionInternal(sessions, token);
return index == -1 ? null : sessions[index];
}
}
internal void Remove(Guid userGuid, ImmutableArray<byte> token) {
if (!userSessions.TryGetValue(userGuid, out var sessions)) {
return;
}
lock (sessions) {
RemoveSessionInternal(sessions, token);
}
}
private static int FindSessionInternal(List<UserSession> sessions, ImmutableArray<byte> token) {
return sessions.FindIndex(s => s.TokenEquals(token));
}
private static void RemoveSessionInternal(List<UserSession> sessions, ImmutableArray<byte> token) {
int index = FindSessionInternal(sessions, token);
if (index != -1) {
sessions.RemoveAt(index);
}
}
}

View File

@ -0,0 +1,62 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Phantom.Common.Logging;
using Phantom.Utils.Tasks;
using ILogger = Serilog.ILogger;
namespace Phantom.Web.Services.Authentication;
public sealed class PhantomLoginStore {
private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginStore>();
private static readonly TimeSpan ExpirationTime = TimeSpan.FromMinutes(1);
internal static Func<IServiceProvider, PhantomLoginStore> Create(CancellationToken cancellationToken) {
return provider => new PhantomLoginStore(provider.GetRequiredService<TaskManager>(), cancellationToken);
}
private readonly ConcurrentDictionary<string, LoginEntry> loginEntries = new ();
private readonly CancellationToken cancellationToken;
private PhantomLoginStore(TaskManager taskManager, CancellationToken cancellationToken) {
this.cancellationToken = cancellationToken;
taskManager.Run("Web login entry expiration loop", RunExpirationLoop);
}
private async Task RunExpirationLoop() {
try {
while (true) {
await Task.Delay(ExpirationTime, cancellationToken);
foreach (var (token, entry) in loginEntries) {
if (entry.IsExpired) {
Logger.Debug("Expired login entry for {Username}.", entry.Username);
loginEntries.TryRemove(token, out _);
}
}
}
} finally {
Logger.Information("Expiration loop stopped.");
}
}
internal void Add(string token, string username, string password, string returnUrl) {
loginEntries[token] = new LoginEntry(username, password, returnUrl, Stopwatch.StartNew());
}
internal LoginEntry? Pop(string token) {
if (!loginEntries.TryRemove(token, out var entry)) {
return null;
}
if (entry.IsExpired) {
Logger.Debug("Expired login entry for {Username}.", entry.Username);
return null;
}
return entry;
}
internal sealed record LoginEntry(string Username, string Password, string ReturnUrl, Stopwatch AddedTime) {
public bool IsExpired => AddedTime.Elapsed >= ExpirationTime;
}
}

View File

@ -1,32 +0,0 @@
using System.Collections.Immutable;
using System.Security.Claims;
using System.Security.Cryptography;
namespace Phantom.Web.Services.Authentication;
sealed class UserSession {
public Guid UserGuid { get; }
public string Username { get; }
public ImmutableArray<byte> Token { get; }
public ClaimsPrincipal AsClaimsPrincipal {
get {
var identity = new ClaimsIdentity(PhantomLoginManager.AuthenticationType);
identity.AddClaim(new Claim(ClaimTypes.Name, Username));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, UserGuid.ToString()));
return new ClaimsPrincipal(identity);
}
}
public UserSession(Guid userGuid, string username, ImmutableArray<byte> token) {
UserGuid = userGuid;
Username = username;
Token = token;
}
public bool TokenEquals(ImmutableArray<byte> other) {
return CryptographicOperations.FixedTimeEquals(Token.AsSpan(), other.AsSpan());
}
}

View File

@ -6,7 +6,7 @@ namespace Phantom.Web.Services.Authorization;
// TODO // TODO
public class PermissionManager { public class PermissionManager {
public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) { public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) {
return IdentityPermissions.None;
} }
public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) { public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {

View File

@ -5,5 +5,5 @@ namespace Phantom.Web.Services;
public interface INavigation { public interface INavigation {
string BasePath { get; } string BasePath { get; }
bool GetQueryParameter(string key, [MaybeNullWhen(false)] out string value); bool GetQueryParameter(string key, [MaybeNullWhen(false)] out string value);
Task NavigateTo(string url, bool forceLoad = false); void NavigateTo(string url, bool forceLoad = false);
} }

View File

@ -1,10 +1,8 @@
using Phantom.Common.Data.Replies; namespace Phantom.Web.Services.Instances;
namespace Phantom.Web.Services.Instances;
// TODO // TODO
public class InstanceManager { public class InstanceManager {
public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) { // public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
//
} // }
} }

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Web.Services.Authentication; using Phantom.Web.Services.Authentication;
using Phantom.Web.Services.Authorization; using Phantom.Web.Services.Authorization;
@ -8,23 +9,23 @@ using Phantom.Web.Services.Rpc;
namespace Phantom.Web.Services; namespace Phantom.Web.Services;
public static class PhantomWebServices { public static class PhantomWebServices {
public static void AddPhantomServices(this IServiceCollection services) { public static void AddPhantomServices(this IServiceCollection services, CancellationToken cancellationToken) {
services.AddSingleton<MessageListener>(); services.AddSingleton<MessageListener>();
services.AddSingleton<ControllerConnection>(); services.AddSingleton<ControllerCommunication>();
services.AddSingleton<PermissionManager>(); services.AddSingleton<PermissionManager>();
services.AddSingleton<PhantomLoginSessions>(); services.AddSingleton(PhantomLoginStore.Create(cancellationToken));
services.AddScoped<PhantomLoginManager>(); services.AddScoped<PhantomLoginManager>();
services.AddScoped<PhantomAuthenticationStateProvider>();
services.AddScoped<AuthenticationStateProvider>(static services => services.GetRequiredService<PhantomAuthenticationStateProvider>());
services.AddScoped<IHostEnvironmentAuthenticationStateProvider>(static services => services.GetRequiredService<PhantomAuthenticationStateProvider>());
services.AddAuthorization(ConfigureAuthorization); services.AddAuthorization(ConfigureAuthorization);
services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>(); services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
} }
public static void UsePhantomServices(this IApplicationBuilder application) { public static void UsePhantomServices(this IApplicationBuilder application) {
application.UseAuthentication();
application.UseAuthorization(); application.UseAuthorization();
application.UseWhen(PhantomIdentityMiddleware.AcceptsPath, static app => app.UseMiddleware<PhantomIdentityMiddleware>());
} }
private static void ConfigureAuthorization(AuthorizationOptions o) { private static void ConfigureAuthorization(AuthorizationOptions o) {

View File

@ -3,10 +3,10 @@ using Phantom.Utils.Rpc;
namespace Phantom.Web.Services.Rpc; namespace Phantom.Web.Services.Rpc;
public sealed class ControllerConnection { public sealed class ControllerCommunication {
private readonly RpcConnectionToServer<IMessageToControllerListener> connection; private readonly RpcConnectionToServer<IMessageToControllerListener> connection;
public ControllerConnection(RpcConnectionToServer<IMessageToControllerListener> connection) { public ControllerCommunication(RpcConnectionToServer<IMessageToControllerListener> connection) {
this.connection = connection; this.connection = connection;
} }
@ -14,7 +14,7 @@ public sealed class ControllerConnection {
return connection.Send(message); return connection.Send(message);
} }
public Task<TReply> Send<TMessage, TReply>(TMessage message, TimeSpan timeout) where TMessage : IMessageToController<TReply> { public Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan timeout) where TMessage : IMessageToController<TReply> where TReply : class {
return connection.Send<TMessage, TReply>(message, timeout, CancellationToken.None); return connection.Send<TMessage, TReply>(message, timeout, CancellationToken.None);
} }
} }

View File

@ -1,4 +1,5 @@
@using Phantom.Web.Services @using Phantom.Web.Services
@using Phantom.Web.Services.Authentication
@inject INavigation Nav @inject INavigation Nav
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@ -7,14 +8,14 @@
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized> <NotAuthorized>
@if (context.User.Identity is { IsAuthenticated: true }) { @if (!PhantomLoginManager.IsAuthenticated(context.User)) {
<h1>Forbidden</h1>
<p role="alert">You do not have permission to visit this page.</p>
}
else {
var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri).TrimEnd('/'); var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri).TrimEnd('/');
Nav.NavigateTo("login" + QueryString.Create("return", returnUrl), forceLoad: true); Nav.NavigateTo("login" + QueryString.Create("return", returnUrl), forceLoad: true);
} }
else {
<h1>Forbidden</h1>
<p role="alert">You do not have permission to visit this page.</p>
}
</NotAuthorized> </NotAuthorized>
</AuthorizeRouteView> </AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" />

View File

@ -1,7 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Web; using System.Web;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Phantom.Web.Services; using Phantom.Web.Services;
namespace Phantom.Web.Base; namespace Phantom.Web.Base;
@ -28,24 +27,7 @@ sealed class Navigation : INavigation {
return value != null; return value != null;
} }
public async Task NavigateTo(string url, bool forceLoad = false) { public void NavigateTo(string url, bool forceLoad = false) {
var newPath = BasePath + url; navigationManager.NavigateTo(BasePath + url, forceLoad);
var navigationTaskSource = new TaskCompletionSource();
navigationManager.LocationChanged += NavigationManagerOnLocationChanged;
try {
navigationManager.NavigateTo(newPath, forceLoad);
await navigationTaskSource.Task.WaitAsync(TimeSpan.FromSeconds(10));
} finally {
navigationManager.LocationChanged -= NavigationManagerOnLocationChanged;
}
return;
void NavigationManagerOnLocationChanged(object? sender, LocationChangedEventArgs e) {
if (Uri.TryCreate(e.Location, UriKind.Absolute, out var uri) && uri.AbsolutePath == newPath) {
navigationTaskSource.SetResult();
}
}
} }
} }

View File

@ -1,90 +1,90 @@
@page "/agents" @* @page "/agents" *@
@using Phantom.Utils.Collections @* @using Phantom.Utils.Collections *@
@implements IDisposable @* @implements IDisposable *@
@inject AgentManager AgentManager @* @inject AgentManager AgentManager *@
@* *@
<h1>Agents</h1> @* <h1>Agents</h1> *@
@* *@
<table class="table align-middle"> @* <table class="table align-middle"> *@
<thead> @* <thead> *@
<tr> @* <tr> *@
<Column Width="200px; 44%">Name</Column> @* <Column Width="200px; 44%">Name</Column> *@
<Column Width=" 90px; 19%" Class="text-end">Instances</Column> @* <Column Width=" 90px; 19%" Class="text-end">Instances</Column> *@
<Column Width="145px; 21%" Class="text-end">Memory</Column> @* <Column Width="145px; 21%" Class="text-end">Memory</Column> *@
<Column Width="180px; 8%">Version</Column> @* <Column Width="180px; 8%">Version</Column> *@
<Column Width="320px">Identifier</Column> @* <Column Width="320px">Identifier</Column> *@
<Column Width="100px; 8%" Class="text-center">Status</Column> @* <Column Width="100px; 8%" Class="text-center">Status</Column> *@
<Column Width="215px" Class="text-end">Last Ping</Column> @* <Column Width="215px" Class="text-end">Last Ping</Column> *@
</tr> @* </tr> *@
</thead> @* </thead> *@
@if (!agentTable.IsEmpty) { @* @if (!agentTable.IsEmpty) { *@
<tbody> @* <tbody> *@
@foreach (var agent in agentTable) { @* @foreach (var agent in agentTable) { *@
var usedInstances = agent.Stats?.RunningInstanceCount; @* var usedInstances = agent.Stats?.RunningInstanceCount; *@
var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes; @* var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes; *@
@* *@
<tr> @* <tr> *@
<td>@agent.Name</td> @* <td>@agent.Name</td> *@
<td class="text-end"> @* <td class="text-end"> *@
<ProgressBar Value="@(usedInstances ?? 0)" Maximum="@agent.MaxInstances"> @* <ProgressBar Value="@(usedInstances ?? 0)" Maximum="@agent.MaxInstances"> *@
@(usedInstances?.ToString() ?? "?") / @agent.MaxInstances @* @(usedInstances?.ToString() ?? "?") / @agent.MaxInstances *@
</ProgressBar> @* </ProgressBar> *@
</td> @* </td> *@
<td class="text-end"> @* <td class="text-end"> *@
<ProgressBar Value="@(usedMemory ?? 0)" Maximum="@agent.MaxMemory.InMegabytes"> @* <ProgressBar Value="@(usedMemory ?? 0)" Maximum="@agent.MaxMemory.InMegabytes"> *@
@(usedMemory?.ToString() ?? "?") / @agent.MaxMemory.InMegabytes MB @* @(usedMemory?.ToString() ?? "?") / @agent.MaxMemory.InMegabytes MB *@
</ProgressBar> @* </ProgressBar> *@
</td> @* </td> *@
<td class="text-condensed"> @* <td class="text-condensed"> *@
Build: <code>@agent.BuildVersion</code> @* Build: <code>@agent.BuildVersion</code> *@
<br> @* <br> *@
Protocol: <code>v@(agent.ProtocolVersion)</code> @* Protocol: <code>v@(agent.ProtocolVersion)</code> *@
</td> @* </td> *@
<td> @* <td> *@
<code class="text-uppercase">@agent.Guid.ToString()</code> @* <code class="text-uppercase">@agent.Guid.ToString()</code> *@
</td> @* </td> *@
@if (agent.IsOnline) { @* @if (agent.IsOnline) { *@
<td class="text-center text-success">Online</td> @* <td class="text-center text-success">Online</td> *@
<td class="text-end"></td> @* <td class="text-end"></td> *@
} @* } *@
else { @* else { *@
<td class="text-center text-danger">Offline</td> @* <td class="text-center text-danger">Offline</td> *@
@if (agent.LastPing is {} lastPing) { @* @if (agent.LastPing is {} lastPing) { *@
<td class="text-end"> @* <td class="text-end"> *@
<time datetime="@lastPing.ToString("o")" data-time-type="relative">@lastPing.ToString()</time> @* <time datetime="@lastPing.ToString("o")" data-time-type="relative">@lastPing.ToString()</time> *@
</td> @* </td> *@
} @* } *@
else { @* else { *@
<td class="text-end">-</td> @* <td class="text-end">-</td> *@
} @* } *@
} @* } *@
</tr> @* </tr> *@
} @* } *@
</tbody> @* </tbody> *@
} @* } *@
else { @* else { *@
<tfoot> @* <tfoot> *@
<tr> @* <tr> *@
<td colspan="7">No agents registered.</td> @* <td colspan="7">No agents registered.</td> *@
</tr> @* </tr> *@
</tfoot> @* </tfoot> *@
} @* } *@
</table> @* </table> *@
@* *@
@code { @* @code { *@
@* *@
private readonly Table<Agent, Guid> agentTable = new(); @* private readonly Table<Agent, Guid> agentTable = new(); *@
@* *@
protected override void OnInitialized() { @* protected override void OnInitialized() { *@
AgentManager.AgentsChanged.Subscribe(this, agents => { @* AgentManager.AgentsChanged.Subscribe(this, agents => { *@
var sortedAgents = agents.Sort(static (a1, a2) => a1.Name.CompareTo(a2.Name)); @* var sortedAgents = agents.Sort(static (a1, a2) => a1.Name.CompareTo(a2.Name)); *@
agentTable.UpdateFrom(sortedAgents, static agent => agent.Guid, static agent => agent, static (agent, _) => agent); @* agentTable.UpdateFrom(sortedAgents, static agent => agent.Guid, static agent => agent, static (agent, _) => agent); *@
InvokeAsync(StateHasChanged); @* InvokeAsync(StateHasChanged); *@
}); @* }); *@
} @* } *@
@* *@
void IDisposable.Dispose() { @* void IDisposable.Dispose() { *@
AgentManager.AgentsChanged.Unsubscribe(this); @* AgentManager.AgentsChanged.Unsubscribe(this); *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,86 +1,86 @@
@page "/audit" @* @page "/audit" *@
@attribute [Authorize(Permission.ViewAuditPolicy)] @* @attribute [Authorize(Permission.ViewAuditPolicy)] *@
@using Phantom.Common.Data.Web.AuditLog @* @using Phantom.Common.Data.Web.AuditLog *@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@using System.Collections.Immutable @* @using System.Collections.Immutable *@
@using Phantom.Web.Services.Instances @* @using Phantom.Web.Services.Instances *@
@implements IDisposable @* @implements IDisposable *@
@inject AuditLog AuditLog @* @inject AuditLog AuditLog *@
@inject InstanceManager InstanceManager @* @inject InstanceManager InstanceManager *@
@inject UserManager UserManager @* @inject UserManager UserManager *@
@* *@
<h1>Audit Log</h1> @* <h1>Audit Log</h1> *@
@* *@
<table class="table"> @* <table class="table"> *@
<thead> @* <thead> *@
<tr> @* <tr> *@
<Column Width="165px" Class="text-end">Time</Column> @* <Column Width="165px" Class="text-end">Time</Column> *@
<Column Width="320px; 20%">User</Column> @* <Column Width="320px; 20%">User</Column> *@
<Column Width="160px">Event Type</Column> @* <Column Width="160px">Event Type</Column> *@
<Column Width="320px; 20%">Subject</Column> @* <Column Width="320px; 20%">Subject</Column> *@
<Column Width="100px; 60%">Data</Column> @* <Column Width="100px; 60%">Data</Column> *@
</tr> @* </tr> *@
</thead> @* </thead> *@
<tbody> @* <tbody> *@
@foreach (var logItem in logItems) { @* @foreach (var logItem in logItems) { *@
DateTimeOffset time = logItem.UtcTime.ToLocalTime(); @* DateTimeOffset time = logItem.UtcTime.ToLocalTime(); *@
<tr> @* <tr> *@
<td class="text-end"> @* <td class="text-end"> *@
<time datetime="@time.ToString("o")">@time.ToString()</time> @* <time datetime="@time.ToString("o")">@time.ToString()</time> *@
</td> @* </td> *@
<td> @* <td> *@
@(logItem.UserName ?? "-") @* @(logItem.UserName ?? "-") *@
<br> @* <br> *@
<code class="text-uppercase">@logItem.UserGuid</code> @* <code class="text-uppercase">@logItem.UserGuid</code> *@
</td> @* </td> *@
<td>@logItem.EventType.ToNiceString()</td> @* <td>@logItem.EventType.ToNiceString()</td> *@
<td> @* <td> *@
@if (logItem.SubjectId is {} subjectId && GetSubjectName(logItem.SubjectType, subjectId) is {} subjectName) { @* @if (logItem.SubjectId is {} subjectId && GetSubjectName(logItem.SubjectType, subjectId) is {} subjectName) { *@
@subjectName @* @subjectName *@
<br> @* <br> *@
} @* } *@
<code class="text-uppercase">@(logItem.SubjectId ?? "-")</code> @* <code class="text-uppercase">@(logItem.SubjectId ?? "-")</code> *@
</td> @* </td> *@
<td> @* <td> *@
<code>@logItem.Data?.RootElement.ToString()</code> @* <code>@logItem.Data?.RootElement.ToString()</code> *@
</td> @* </td> *@
</tr> @* </tr> *@
} @* } *@
</tbody> @* </tbody> *@
</table> @* </table> *@
@* *@
@code { @* @code { *@
@* *@
private CancellationTokenSource? initializationCancellationTokenSource; @* private CancellationTokenSource? initializationCancellationTokenSource; *@
private AuditLogItem[] logItems = Array.Empty<AuditLogItem>(); @* private AuditLogItem[] logItems = Array.Empty<AuditLogItem>(); *@
private Dictionary<Guid, string>? userNamesByGuid; @* private Dictionary<Guid, string>? userNamesByGuid; *@
private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty; @* private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty; *@
@* *@
protected override async Task OnInitializedAsync() { @* protected override async Task OnInitializedAsync() { *@
initializationCancellationTokenSource = new CancellationTokenSource(); @* initializationCancellationTokenSource = new CancellationTokenSource(); *@
var cancellationToken = initializationCancellationTokenSource.Token; @* var cancellationToken = initializationCancellationTokenSource.Token; *@
@* *@
try { @* try { *@
logItems = await AuditLog.GetItems(50, cancellationToken); @* logItems = await AuditLog.GetItems(50, cancellationToken); *@
userNamesByGuid = await UserManager.GetAllByGuid(static user => user.Name, cancellationToken); @* userNamesByGuid = await UserManager.GetAllByGuid(static user => user.Name, cancellationToken); *@
instanceNamesByGuid = InstanceManager.GetInstanceNames(); @* instanceNamesByGuid = InstanceManager.GetInstanceNames(); *@
} finally { @* } finally { *@
initializationCancellationTokenSource.Dispose(); @* initializationCancellationTokenSource.Dispose(); *@
} @* } *@
} @* } *@
@* *@
private string? GetSubjectName(AuditLogSubjectType type, string id) { @* private string? GetSubjectName(AuditLogSubjectType type, string id) { *@
return type switch { @* return type switch { *@
AuditLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, @* AuditLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, *@
AuditLogSubjectType.User => userNamesByGuid != null && userNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, @* AuditLogSubjectType.User => userNamesByGuid != null && userNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, *@
_ => null @* _ => null *@
}; @* }; *@
} @* } *@
@* *@
public void Dispose() { @* public void Dispose() { *@
try { @* try { *@
initializationCancellationTokenSource?.Cancel(); @* initializationCancellationTokenSource?.Cancel(); *@
} catch (ObjectDisposedException) {} @* } catch (ObjectDisposedException) {} *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,95 +1,95 @@
@page "/events" @* @page "/events" *@
@attribute [Authorize(Permission.ViewEventsPolicy)] @* @attribute [Authorize(Permission.ViewEventsPolicy)] *@
@using Phantom.Common.Data.Web.EventLog @* @using Phantom.Common.Data.Web.EventLog *@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@using System.Collections.Immutable @* @using System.Collections.Immutable *@
@using System.Diagnostics @* @using System.Diagnostics *@
@using Phantom.Web.Services.Instances @* @using Phantom.Web.Services.Instances *@
@implements IDisposable @* @implements IDisposable *@
@inject AgentManager AgentManager @* @inject AgentManager AgentManager *@
@inject EventLog EventLog @* @inject EventLog EventLog *@
@inject InstanceManager InstanceManager @* @inject InstanceManager InstanceManager *@
@* *@
<h1>Event Log</h1> @* <h1>Event Log</h1> *@
@* *@
<table class="table"> @* <table class="table"> *@
<thead> @* <thead> *@
<tr> @* <tr> *@
<Column Width="165px" Class="text-end">Time</Column> @* <Column Width="165px" Class="text-end">Time</Column> *@
<Column Width="320px; 20%">Agent</Column> @* <Column Width="320px; 20%">Agent</Column> *@
<Column Width="160px">Event Type</Column> @* <Column Width="160px">Event Type</Column> *@
<Column Width="320px; 20%">Subject</Column> @* <Column Width="320px; 20%">Subject</Column> *@
<Column Width="100px; 60%">Data</Column> @* <Column Width="100px; 60%">Data</Column> *@
</tr> @* </tr> *@
</thead> @* </thead> *@
<tbody> @* <tbody> *@
@foreach (var logItem in logItems) { @* @foreach (var logItem in logItems) { *@
DateTimeOffset time = logItem.UtcTime.ToLocalTime(); @* DateTimeOffset time = logItem.UtcTime.ToLocalTime(); *@
<tr> @* <tr> *@
<td class="text-end"> @* <td class="text-end"> *@
<time datetime="@time.ToString("o")">@time.ToString()</time> @* <time datetime="@time.ToString("o")">@time.ToString()</time> *@
</td> @* </td> *@
<td> @* <td> *@
@if (logItem.AgentGuid is {} agentGuid) { @* @if (logItem.AgentGuid is {} agentGuid) { *@
@(GetAgentName(agentGuid)) @* @(GetAgentName(agentGuid)) *@
<br> @* <br> *@
<code class="text-uppercase">@agentGuid</code> @* <code class="text-uppercase">@agentGuid</code> *@
} @* } *@
else { @* else { *@
<text>-</text> @* <text>-</text> *@
} @* } *@
</td> @* </td> *@
<td>@logItem.EventType.ToNiceString()</td> @* <td>@logItem.EventType.ToNiceString()</td> *@
<td> @* <td> *@
@if (GetSubjectName(logItem.SubjectType, logItem.SubjectId) is {} subjectName) { @* @if (GetSubjectName(logItem.SubjectType, logItem.SubjectId) is {} subjectName) { *@
@subjectName @* @subjectName *@
<br> @* <br> *@
} @* } *@
<code class="text-uppercase">@logItem.SubjectId</code> @* <code class="text-uppercase">@logItem.SubjectId</code> *@
</td> @* </td> *@
<td> @* <td> *@
<code>@logItem.Data?.RootElement.ToString()</code> @* <code>@logItem.Data?.RootElement.ToString()</code> *@
</td> @* </td> *@
</tr> @* </tr> *@
} @* } *@
</tbody> @* </tbody> *@
</table> @* </table> *@
@* *@
@code { @* @code { *@
@* *@
private CancellationTokenSource? initializationCancellationTokenSource; @* private CancellationTokenSource? initializationCancellationTokenSource; *@
private EventLogItem[] logItems = Array.Empty<EventLogItem>(); @* private EventLogItem[] logItems = Array.Empty<EventLogItem>(); *@
private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty; @* private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty; *@
private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty; @* private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty; *@
@* *@
protected override async Task OnInitializedAsync() { @* protected override async Task OnInitializedAsync() { *@
initializationCancellationTokenSource = new CancellationTokenSource(); @* initializationCancellationTokenSource = new CancellationTokenSource(); *@
var cancellationToken = initializationCancellationTokenSource.Token; @* var cancellationToken = initializationCancellationTokenSource.Token; *@
@* *@
try { @* try { *@
logItems = await EventLog.GetItems(50, cancellationToken); @* logItems = await EventLog.GetItems(50, cancellationToken); *@
agentNamesByGuid = AgentManager.GetAgents().ToImmutableDictionary(static kvp => kvp.Key, static kvp => kvp.Value.Name); @* agentNamesByGuid = AgentManager.GetAgents().ToImmutableDictionary(static kvp => kvp.Key, static kvp => kvp.Value.Name); *@
instanceNamesByGuid = InstanceManager.GetInstanceNames(); @* instanceNamesByGuid = InstanceManager.GetInstanceNames(); *@
} finally { @* } finally { *@
initializationCancellationTokenSource.Dispose(); @* initializationCancellationTokenSource.Dispose(); *@
} @* } *@
} @* } *@
@* *@
private string GetAgentName(Guid agentGuid) { @* private string GetAgentName(Guid agentGuid) { *@
return agentNamesByGuid.TryGetValue(agentGuid, out var name) ? name : "?"; @* return agentNamesByGuid.TryGetValue(agentGuid, out var name) ? name : "?"; *@
} @* } *@
@* *@
private string? GetSubjectName(EventLogSubjectType type, string id) { @* private string? GetSubjectName(EventLogSubjectType type, string id) { *@
return type switch { @* return type switch { *@
EventLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, @* EventLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, *@
_ => null @* _ => null *@
}; @* }; *@
} @* } *@
@* *@
public void Dispose() { @* public void Dispose() { *@
try { @* try { *@
initializationCancellationTokenSource?.Cancel(); @* initializationCancellationTokenSource?.Cancel(); *@
} catch (ObjectDisposedException) {} @* } catch (ObjectDisposedException) {} *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,91 +1,91 @@
@page "/instances/{InstanceGuid:guid}" @* @page "/instances/{InstanceGuid:guid}" *@
@attribute [Authorize(Permission.ViewInstancesPolicy)] @* @attribute [Authorize(Permission.ViewInstancesPolicy)] *@
@inherits PhantomComponent @* @inherits PhantomComponent *@
@using Phantom.Web.Services.Instances @* @using Phantom.Web.Services.Instances *@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@using Phantom.Common.Data.Replies @* @using Phantom.Common.Data.Replies *@
@implements IDisposable @* @implements IDisposable *@
@inject InstanceManager InstanceManager @* @inject InstanceManager InstanceManager *@
@inject AuditLog AuditLog @* @inject AuditLog AuditLog *@
@* *@
@if (Instance == null) { @* @if (Instance == null) { *@
<h1>Instance Not Found</h1> @* <h1>Instance Not Found</h1> *@
<p>Return to <a href="instances">all instances</a>.</p> @* <p>Return to <a href="instances">all instances</a>.</p> *@
} @* } *@
else { @* else { *@
<h1>Instance: @Instance.Configuration.InstanceName</h1> @* <h1>Instance: @Instance.Configuration.InstanceName</h1> *@
<div class="d-flex flex-row align-items-center gap-2"> @* <div class="d-flex flex-row align-items-center gap-2"> *@
<PermissionView Permission="Permission.ControlInstances"> @* <PermissionView Permission="Permission.ControlInstances"> *@
<button type="button" class="btn btn-success" @onclick="LaunchInstance" disabled="@(isLaunchingInstance || !Instance.Status.CanLaunch())">Launch</button> @* <button type="button" class="btn btn-success" @onclick="LaunchInstance" disabled="@(isLaunchingInstance || !Instance.Status.CanLaunch())">Launch</button> *@
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#stop-instance" disabled="@(!Instance.Status.CanStop())">Stop...</button> @* <button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#stop-instance" disabled="@(!Instance.Status.CanStop())">Stop...</button> *@
<span><!-- extra spacing --></span> @* <span><!-- extra spacing --></span> *@
</PermissionView> @* </PermissionView> *@
<InstanceStatusText Status="Instance.Status" /> @* <InstanceStatusText Status="Instance.Status" /> *@
<PermissionView Permission="Permission.CreateInstances"> @* <PermissionView Permission="Permission.CreateInstances"> *@
<a href="instances/@InstanceGuid/edit" class="btn btn-warning ms-auto">Edit Configuration</a> @* <a href="instances/@InstanceGuid/edit" class="btn btn-warning ms-auto">Edit Configuration</a> *@
</PermissionView> @* </PermissionView> *@
</div> @* </div> *@
@if (lastError != null) { @* @if (lastError != null) { *@
<p class="text-danger mt-2">@lastError</p> @* <p class="text-danger mt-2">@lastError</p> *@
} @* } *@
@* *@
<PermissionView Permission="Permission.ViewInstanceLogs"> @* <PermissionView Permission="Permission.ViewInstanceLogs"> *@
<InstanceLog InstanceGuid="InstanceGuid" /> @* <InstanceLog InstanceGuid="InstanceGuid" /> *@
</PermissionView> @* </PermissionView> *@
@* *@
<PermissionView Permission="Permission.ControlInstances"> @* <PermissionView Permission="Permission.ControlInstances"> *@
<div class="mb-3"> @* <div class="mb-3"> *@
<InstanceCommandInput InstanceGuid="InstanceGuid" Disabled="@(!Instance.Status.CanSendCommand())" /> @* <InstanceCommandInput InstanceGuid="InstanceGuid" Disabled="@(!Instance.Status.CanSendCommand())" /> *@
</div> @* </div> *@
@* *@
<InstanceStopDialog InstanceGuid="InstanceGuid" ModalId="stop-instance" Disabled="@(!Instance.Status.CanStop())" /> @* <InstanceStopDialog InstanceGuid="InstanceGuid" ModalId="stop-instance" Disabled="@(!Instance.Status.CanStop())" /> *@
</PermissionView> @* </PermissionView> *@
} @* } *@
@* *@
@code { @* @code { *@
@* *@
[Parameter] @* [Parameter] *@
public Guid InstanceGuid { get; set; } @* public Guid InstanceGuid { get; set; } *@
@* *@
private string? lastError = null; @* private string? lastError = null; *@
private bool isLaunchingInstance = false; @* private bool isLaunchingInstance = false; *@
@* *@
private Instance? Instance { get; set; } @* private Instance? Instance { get; set; } *@
@* *@
protected override void OnInitialized() { @* protected override void OnInitialized() { *@
InstanceManager.InstancesChanged.Subscribe(this, instances => { @* InstanceManager.InstancesChanged.Subscribe(this, instances => { *@
var newInstance = instances.TryGetValue(InstanceGuid, out var instance) ? instance : null; @* var newInstance = instances.TryGetValue(InstanceGuid, out var instance) ? instance : null; *@
if (newInstance != Instance) { @* if (newInstance != Instance) { *@
Instance = newInstance; @* Instance = newInstance; *@
InvokeAsync(StateHasChanged); @* InvokeAsync(StateHasChanged); *@
} @* } *@
}); @* }); *@
} @* } *@
@* *@
private async Task LaunchInstance() { @* private async Task LaunchInstance() { *@
isLaunchingInstance = true; @* isLaunchingInstance = true; *@
lastError = null; @* lastError = null; *@
@* *@
try { @* try { *@
if (!await CheckPermission(Permission.ControlInstances)) { @* if (!await CheckPermission(Permission.ControlInstances)) { *@
lastError = "You do not have permission to launch instances."; @* lastError = "You do not have permission to launch instances."; *@
return; @* return; *@
} @* } *@
@* *@
var result = await InstanceManager.LaunchInstance(InstanceGuid); @* var result = await InstanceManager.LaunchInstance(InstanceGuid); *@
if (result.Is(LaunchInstanceResult.LaunchInitiated)) { @* if (result.Is(LaunchInstanceResult.LaunchInitiated)) { *@
await AuditLog.AddInstanceLaunchedEvent(InstanceGuid); @* await AuditLog.AddInstanceLaunchedEvent(InstanceGuid); *@
} @* } *@
else { @* else { *@
lastError = result.ToSentence(Messages.ToSentence); @* lastError = result.ToSentence(Messages.ToSentence); *@
} @* } *@
} finally { @* } finally { *@
isLaunchingInstance = false; @* isLaunchingInstance = false; *@
} @* } *@
} @* } *@
@* *@
public void Dispose() { @* public void Dispose() { *@
InstanceManager.InstancesChanged.Unsubscribe(this); @* InstanceManager.InstancesChanged.Unsubscribe(this); *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,29 +1,29 @@
@page "/instances/{InstanceGuid:guid}/edit" @* @page "/instances/{InstanceGuid:guid}/edit" *@
@attribute [Authorize(Permission.CreateInstancesPolicy)] @* @attribute [Authorize(Permission.CreateInstancesPolicy)] *@
@using Phantom.Common.Data.Instance @* @using Phantom.Common.Data.Instance *@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@using Phantom.Web.Services.Instances @* @using Phantom.Web.Services.Instances *@
@inherits PhantomComponent @* @inherits PhantomComponent *@
@inject InstanceManager InstanceManager @* @inject InstanceManager InstanceManager *@
@* *@
@if (InstanceConfiguration == null) { @* @if (InstanceConfiguration == null) { *@
<h1>Instance Not Found</h1> @* <h1>Instance Not Found</h1> *@
<p>Return to <a href="instances">all instances</a>.</p> @* <p>Return to <a href="instances">all instances</a>.</p> *@
} @* } *@
else { @* else { *@
<h1>Edit Instance: @InstanceConfiguration.InstanceName</h1> @* <h1>Edit Instance: @InstanceConfiguration.InstanceName</h1> *@
<InstanceAddOrEditForm EditedInstanceConfiguration="InstanceConfiguration" /> @* <InstanceAddOrEditForm EditedInstanceConfiguration="InstanceConfiguration" /> *@
} @* } *@
@* *@
@code { @* @code { *@
@* *@
[Parameter] @* [Parameter] *@
public Guid InstanceGuid { get; set; } @* public Guid InstanceGuid { get; set; } *@
@* *@
private InstanceConfiguration? InstanceConfiguration { get; set; } @* private InstanceConfiguration? InstanceConfiguration { get; set; } *@
@* *@
protected override void OnInitialized() { @* protected override void OnInitialized() { *@
InstanceConfiguration = InstanceManager.GetInstanceConfiguration(InstanceGuid); @* InstanceConfiguration = InstanceManager.GetInstanceConfiguration(InstanceGuid); *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,92 +1,92 @@
@page "/instances" @* @page "/instances" *@
@attribute [Authorize(Permission.ViewInstancesPolicy)] @* @attribute [Authorize(Permission.ViewInstancesPolicy)] *@
@using System.Collections.Immutable @* @using System.Collections.Immutable *@
@implements IDisposable @* @implements IDisposable *@
@inject AgentManager AgentManager @* @inject AgentManager AgentManager *@
@inject InstanceManager InstanceManager @* @inject InstanceManager InstanceManager *@
@* *@
<h1>Instances</h1> @* <h1>Instances</h1> *@
@* *@
<PermissionView Permission="Permission.CreateInstances"> @* <PermissionView Permission="Permission.CreateInstances"> *@
<a href="instances/create" class="btn btn-primary" role="button">New Instance</a> @* <a href="instances/create" class="btn btn-primary" role="button">New Instance</a> *@
</PermissionView> @* </PermissionView> *@
@* *@
<table class="table align-middle"> @* <table class="table align-middle"> *@
<thead> @* <thead> *@
<tr> @* <tr> *@
<Column Width="200px; 28%">Agent</Column> @* <Column Width="200px; 28%">Agent</Column> *@
<Column Width="200px; 28%">Name</Column> @* <Column Width="200px; 28%">Name</Column> *@
<Column Width="130px; 11%">Version</Column> @* <Column Width="130px; 11%">Version</Column> *@
<Column Width="110px; 8%" Class="text-center">Server Port</Column> @* <Column Width="110px; 8%" Class="text-center">Server Port</Column> *@
<Column Width="110px; 8%" Class="text-center">Rcon Port</Column> @* <Column Width="110px; 8%" Class="text-center">Rcon Port</Column> *@
<Column Width=" 90px; 8%" Class="text-end">Memory</Column> @* <Column Width=" 90px; 8%" Class="text-end">Memory</Column> *@
<Column Width="320px">Identifier</Column> @* <Column Width="320px">Identifier</Column> *@
<Column Width="200px; 9%">Status</Column> @* <Column Width="200px; 9%">Status</Column> *@
<Column Width=" 75px">Actions</Column> @* <Column Width=" 75px">Actions</Column> *@
</tr> @* </tr> *@
</thead> @* </thead> *@
@if (!instances.IsEmpty) { @* @if (!instances.IsEmpty) { *@
<tbody> @* <tbody> *@
@foreach (var (configuration, status, _) in instances) { @* @foreach (var (configuration, status, _) in instances) { *@
var agentName = agentNames.TryGetValue(configuration.AgentGuid, out var name) ? name : string.Empty; @* var agentName = agentNames.TryGetValue(configuration.AgentGuid, out var name) ? name : string.Empty; *@
var instanceGuid = configuration.InstanceGuid.ToString(); @* var instanceGuid = configuration.InstanceGuid.ToString(); *@
<tr> @* <tr> *@
<td>@agentName</td> @* <td>@agentName</td> *@
<td>@configuration.InstanceName</td> @* <td>@configuration.InstanceName</td> *@
<td>@configuration.MinecraftServerKind @configuration.MinecraftVersion</td> @* <td>@configuration.MinecraftServerKind @configuration.MinecraftVersion</td> *@
<td class="text-center"> @* <td class="text-center"> *@
<code>@configuration.ServerPort</code> @* <code>@configuration.ServerPort</code> *@
</td> @* </td> *@
<td class="text-center"> @* <td class="text-center"> *@
<code>@configuration.RconPort</code> @* <code>@configuration.RconPort</code> *@
</td> @* </td> *@
<td class="text-end"> @* <td class="text-end"> *@
<code>@configuration.MemoryAllocation.InMegabytes MB</code> @* <code>@configuration.MemoryAllocation.InMegabytes MB</code> *@
</td> @* </td> *@
<td> @* <td> *@
<code class="text-uppercase">@instanceGuid</code> @* <code class="text-uppercase">@instanceGuid</code> *@
</td> @* </td> *@
<td> @* <td> *@
<InstanceStatusText Status="status" /> @* <InstanceStatusText Status="status" /> *@
</td> @* </td> *@
<td> @* <td> *@
<a href="instances/@instanceGuid" class="btn btn-info btn-sm">Detail</a> @* <a href="instances/@instanceGuid" class="btn btn-info btn-sm">Detail</a> *@
</td> @* </td> *@
</tr> @* </tr> *@
} @* } *@
</tbody> @* </tbody> *@
} @* } *@
@if (instances.IsEmpty) { @* @if (instances.IsEmpty) { *@
<tfoot> @* <tfoot> *@
<tr> @* <tr> *@
<td colspan="9"> @* <td colspan="9"> *@
No instances. @* No instances. *@
</td> @* </td> *@
</tr> @* </tr> *@
</tfoot> @* </tfoot> *@
} @* } *@
</table> @* </table> *@
@* *@
@code { @* @code { *@
@* *@
private ImmutableDictionary<Guid, string> agentNames = ImmutableDictionary<Guid, string>.Empty; @* private ImmutableDictionary<Guid, string> agentNames = ImmutableDictionary<Guid, string>.Empty; *@
private ImmutableArray<Instance> instances = ImmutableArray<Instance>.Empty; @* private ImmutableArray<Instance> instances = ImmutableArray<Instance>.Empty; *@
@* *@
protected override void OnInitialized() { @* protected override void OnInitialized() { *@
AgentManager.AgentsChanged.Subscribe(this, agents => { @* AgentManager.AgentsChanged.Subscribe(this, agents => { *@
this.agentNames = agents.ToImmutableDictionary(static agent => agent.Guid, static agent => agent.Name); @* this.agentNames = agents.ToImmutableDictionary(static agent => agent.Guid, static agent => agent.Name); *@
InvokeAsync(StateHasChanged); @* InvokeAsync(StateHasChanged); *@
}); @* }); *@
@* *@
InstanceManager.InstancesChanged.Subscribe(this, instances => { @* InstanceManager.InstancesChanged.Subscribe(this, instances => { *@
this.instances = instances.Values.OrderBy(instance => agentNames.TryGetValue(instance.Configuration.AgentGuid, out var agentName) ? agentName : string.Empty).ThenBy(static instance => instance.Configuration.InstanceName).ToImmutableArray(); @* this.instances = instances.Values.OrderBy(instance => agentNames.TryGetValue(instance.Configuration.AgentGuid, out var agentName) ? agentName : string.Empty).ThenBy(static instance => instance.Configuration.InstanceName).ToImmutableArray(); *@
InvokeAsync(StateHasChanged); @* InvokeAsync(StateHasChanged); *@
}); @* }); *@
} @* } *@
@* *@
void IDisposable.Dispose() { @* void IDisposable.Dispose() { *@
AgentManager.AgentsChanged.Unsubscribe(this); @* AgentManager.AgentsChanged.Unsubscribe(this); *@
InstanceManager.InstancesChanged.Unsubscribe(this); @* InstanceManager.InstancesChanged.Unsubscribe(this); *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,10 +1,12 @@
@page "/login" @page "/login"
@using Phantom.Web.Services @using Phantom.Web.Services
@using Phantom.Web.Services.Authentication @using Phantom.Web.Services.Authentication
@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
@inject INavigation Navigation @inject INavigation Navigation
@inject PhantomLoginManager LoginManager @inject PhantomLoginManager LoginManager
@inject ProtectedSessionStorage ProtectedSessionStore
<h1>Login</h1> <h1>Login</h1>

View File

@ -1,11 +0,0 @@
@page "/logout"
@using Phantom.Web.Services.Authentication
@inject PhantomLoginManager LoginManager
@code {
protected override Task OnInitializedAsync() {
return LoginManager.SignOut();
}
}

View File

@ -11,7 +11,7 @@
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
@inject ServiceConfiguration ServiceConfiguration @inject ServiceConfiguration ServiceConfiguration
@inject PhantomLoginManager LoginManager @inject PhantomLoginManager LoginManager
@inject ControllerConnection ControllerConnection @inject ControllerCommunication ControllerCommunication
<h1>Administrator Setup</h1> <h1>Administrator Setup</h1>
@ -87,7 +87,7 @@
} }
private async Task<Result<string>> CreateOrUpdateAdministrator() { private async Task<Result<string>> CreateOrUpdateAdministrator() {
var reply = await ControllerConnection.Send<CreateOrUpdateAdministratorUser, CreateOrUpdateAdministratorUserResult>(new CreateOrUpdateAdministratorUser(form.Username, form.Password), Timeout.InfiniteTimeSpan); var reply = await ControllerCommunication.Send<CreateOrUpdateAdministratorUser, CreateOrUpdateAdministratorUserResult>(new CreateOrUpdateAdministratorUser(form.Username, form.Password), Timeout.InfiniteTimeSpan);
return reply switch { return reply switch {
Success => Result.Ok<string>(), Success => Result.Ok<string>(),
CreationFailed fail => fail.Error.ToSentences("\n"), CreationFailed fail => fail.Error.ToSentences("\n"),

View File

@ -1,110 +1,110 @@
@page "/users" @* @page "/users" *@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@using System.Collections.Immutable @* @using System.Collections.Immutable *@
@using Phantom.Web.Services.Authorization @* @using Phantom.Web.Services.Authorization *@
@attribute [Authorize(Permission.ViewUsersPolicy)] @* @attribute [Authorize(Permission.ViewUsersPolicy)] *@
@inject UserManager UserManager @* @inject UserManager UserManager *@
@inject UserRoleManager UserRoleManager @* @inject UserRoleManager UserRoleManager *@
@inject PermissionManager PermissionManager @* @inject PermissionManager PermissionManager *@
@* *@
<h1>Users</h1> @* <h1>Users</h1> *@
@* *@
<PermissionView Permission="Permission.EditUsers"> @* <PermissionView Permission="Permission.EditUsers"> *@
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-user">Add User...</button> @* <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-user">Add User...</button> *@
</PermissionView> @* </PermissionView> *@
@* *@
<AuthorizeView> @* <AuthorizeView> *@
<Authorized> @* <Authorized> *@
@{ var canEdit = PermissionManager.CheckPermission(context.User, Permission.EditUsers); } @* @{ var canEdit = PermissionManager.CheckPermission(context.User, Permission.EditUsers); } *@
<table class="table align-middle"> @* <table class="table align-middle"> *@
<thead> @* <thead> *@
<tr> @* <tr> *@
<Column Width="320px">Identifier</Column> @* <Column Width="320px">Identifier</Column> *@
<Column Width="125px; 40%">Username</Column> @* <Column Width="125px; 40%">Username</Column> *@
<Column Width="125px; 60%">Roles</Column> @* <Column Width="125px; 60%">Roles</Column> *@
@if (canEdit) { @* @if (canEdit) { *@
<Column Width="175px">Actions</Column> @* <Column Width="175px">Actions</Column> *@
} @* } *@
</tr> @* </tr> *@
</thead> @* </thead> *@
<tbody> @* <tbody> *@
@{ var myUserId = UserManager.GetAuthenticatedUserId(context.User); } @* @{ var myUserId = UserManager.GetAuthenticatedUserId(context.User); } *@
@foreach (var user in allUsers) { @* @foreach (var user in allUsers) { *@
var isMe = myUserId == user.Guid; @* var isMe = myUserId == user.Guid; *@
<tr> @* <tr> *@
<td> @* <td> *@
<code class="text-uppercase">@user.Guid</code> @* <code class="text-uppercase">@user.Guid</code> *@
</td> @* </td> *@
@if (isMe) { @* @if (isMe) { *@
<td class="fw-semibold">@user.Name</td> @* <td class="fw-semibold">@user.Name</td> *@
} @* } *@
else { @* else { *@
<td>@user.Name</td> @* <td>@user.Name</td> *@
} @* } *@
<td>@(userGuidToRoleDescription.TryGetValue(user.Guid, out var roles) ? roles : "?")</td> @* <td>@(userGuidToRoleDescription.TryGetValue(user.Guid, out var roles) ? roles : "?")</td> *@
@if (canEdit) { @* @if (canEdit) { *@
<td> @* <td> *@
@if (!isMe) { @* @if (!isMe) { *@
<button class="btn btn-primary btn-sm" @onclick="() => userRolesDialog.Show(user)">Edit Roles</button> @* <button class="btn btn-primary btn-sm" @onclick="() => userRolesDialog.Show(user)">Edit Roles</button> *@
<button class="btn btn-danger btn-sm" @onclick="() => userDeleteDialog.Show(user)">Delete...</button> @* <button class="btn btn-danger btn-sm" @onclick="() => userDeleteDialog.Show(user)">Delete...</button> *@
} @* } *@
</td> @* </td> *@
} @* } *@
</tr> @* </tr> *@
} @* } *@
</tbody> @* </tbody> *@
</table> @* </table> *@
</Authorized> @* </Authorized> *@
</AuthorizeView> @* </AuthorizeView> *@
@* *@
<PermissionView Permission="Permission.EditUsers"> @* <PermissionView Permission="Permission.EditUsers"> *@
<UserAddDialog ModalId="add-user" UserAdded="OnUserAdded" /> @* <UserAddDialog ModalId="add-user" UserAdded="OnUserAdded" /> *@
<UserRolesDialog @ref="userRolesDialog" ModalId="manage-user-roles" UserModified="OnUserRolesChanged" /> @* <UserRolesDialog @ref="userRolesDialog" ModalId="manage-user-roles" UserModified="OnUserRolesChanged" /> *@
<UserDeleteDialog @ref="userDeleteDialog" ModalId="delete-user" UserModified="OnUserDeleted" /> @* <UserDeleteDialog @ref="userDeleteDialog" ModalId="delete-user" UserModified="OnUserDeleted" /> *@
</PermissionView> @* </PermissionView> *@
@* *@
@code { @* @code { *@
@* *@
private ImmutableArray<UserInfo> allUsers = ImmutableArray<UserInfo>.Empty; @* private ImmutableArray<UserInfo> allUsers = ImmutableArray<UserInfo>.Empty; *@
private readonly Dictionary<Guid, string> userGuidToRoleDescription = new(); @* private readonly Dictionary<Guid, string> userGuidToRoleDescription = new(); *@
@* *@
private UserRolesDialog userRolesDialog = null!; @* private UserRolesDialog userRolesDialog = null!; *@
private UserDeleteDialog userDeleteDialog = null!; @* private UserDeleteDialog userDeleteDialog = null!; *@
@* *@
protected override async Task OnInitializedAsync() { @* protected override async Task OnInitializedAsync() { *@
var unsortedUsers = await UserManager.GetAll(); @* var unsortedUsers = await UserManager.GetAll(); *@
allUsers = unsortedUsers.Sort(static (a, b) => a.Name.CompareTo(b.Name)); @* allUsers = unsortedUsers.Sort(static (a, b) => a.Name.CompareTo(b.Name)); *@
@* *@
foreach (var (userGuid, roles) in await UserRoleManager.GetAllByUserGuid()) { @* foreach (var (userGuid, roles) in await UserRoleManager.GetAllByUserGuid()) { *@
userGuidToRoleDescription[userGuid] = StringifyRoles(roles); @* userGuidToRoleDescription[userGuid] = StringifyRoles(roles); *@
} @* } *@
@* *@
foreach (var user in allUsers) { @* foreach (var user in allUsers) { *@
await RefreshUserRoles(user); @* await RefreshUserRoles(user); *@
} @* } *@
} @* } *@
@* *@
private async Task RefreshUserRoles(UserInfo user) { @* private async Task RefreshUserRoles(UserInfo user) { *@
var roles = await UserRoleManager.GetUserRoles(user); @* var roles = await UserRoleManager.GetUserRoles(user); *@
userGuidToRoleDescription[user.Guid] = StringifyRoles(roles); @* userGuidToRoleDescription[user.Guid] = StringifyRoles(roles); *@
} @* } *@
@* *@
private static string StringifyRoles(ImmutableArray<RoleInfo> roles) { @* private static string StringifyRoles(ImmutableArray<RoleInfo> roles) { *@
return roles.IsEmpty ? "-" : string.Join(", ", roles.Select(static role => role.Name)); @* return roles.IsEmpty ? "-" : string.Join(", ", roles.Select(static role => role.Name)); *@
} @* } *@
@* *@
private Task OnUserAdded(UserInfo user) { @* private Task OnUserAdded(UserInfo user) { *@
allUsers = allUsers.Add(user); @* allUsers = allUsers.Add(user); *@
return RefreshUserRoles(user); @* return RefreshUserRoles(user); *@
} @* } *@
@* *@
private Task OnUserRolesChanged(UserInfo user) { @* private Task OnUserRolesChanged(UserInfo user) { *@
return RefreshUserRoles(user); @* return RefreshUserRoles(user); *@
} @* } *@
@* *@
private void OnUserDeleted(UserInfo user) { @* private void OnUserDeleted(UserInfo user) { *@
allUsers = allUsers.Remove(user); @* allUsers = allUsers.Remove(user); *@
userGuidToRoleDescription.Remove(user.Guid); @* userGuidToRoleDescription.Remove(user.Guid); *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,338 +1,338 @@
@using Phantom.Web.Components.Utils @* @using Phantom.Web.Components.Utils *@
@using Phantom.Common.Data.Minecraft @* @using Phantom.Common.Data.Minecraft *@
@using Phantom.Common.Data.Web.Minecraft @* @using Phantom.Common.Data.Web.Minecraft *@
@using Phantom.Common.Data.Instance @* @using Phantom.Common.Data.Instance *@
@using Phantom.Common.Data.Java @* @using Phantom.Common.Data.Java *@
@using System.Collections.Immutable @* @using System.Collections.Immutable *@
@using System.ComponentModel.DataAnnotations @* @using System.ComponentModel.DataAnnotations *@
@using System.Diagnostics.CodeAnalysis @* @using System.Diagnostics.CodeAnalysis *@
@using Phantom.Common.Data @* @using Phantom.Common.Data *@
@using Phantom.Web.Services @* @using Phantom.Web.Services *@
@using Phantom.Web.Services.Instances @* @using Phantom.Web.Services.Instances *@
@inject INavigation Nav @* @inject INavigation Nav *@
@inject MinecraftVersions MinecraftVersions @* @inject MinecraftVersions MinecraftVersions *@
@inject AgentManager AgentManager @* @inject AgentManager AgentManager *@
@inject AgentJavaRuntimesManager AgentJavaRuntimesManager @* @inject AgentJavaRuntimesManager AgentJavaRuntimesManager *@
@inject InstanceManager InstanceManager @* @inject InstanceManager InstanceManager *@
@inject AuditLog AuditLog @* @inject AuditLog AuditLog *@
@* *@
<Form Model="form" OnSubmit="AddOrEditInstance"> @* <Form Model="form" OnSubmit="AddOrEditInstance"> *@
@{ var selectedAgent = form.SelectedAgent; } @* @{ var selectedAgent = form.SelectedAgent; } *@
<div class="row"> @* <div class="row"> *@
<div class="col-xl-7 mb-3"> @* <div class="col-xl-7 mb-3"> *@
@{ @* @{ *@
static RenderFragment GetAgentOption(Agent agent) { @* static RenderFragment GetAgentOption(Agent agent) { *@
return @<option value="@agent.Guid"> @* return @<option value="@agent.Guid"> *@
@agent.Name @* @agent.Name *@
&bullet; @* &bullet; *@
@(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(agent.MaxInstances) @(agent.MaxInstances == 1 ? "Instance" : "Instances") @* @(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(agent.MaxInstances) @(agent.MaxInstances == 1 ? "Instance" : "Instances") *@
&bullet; @* &bullet; *@
@(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(agent.MaxMemory.InMegabytes) MB RAM @* @(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(agent.MaxMemory.InMegabytes) MB RAM *@
</option>; @* </option>; *@
} @* } *@
} @* } *@
@if (EditedInstanceConfiguration == null) { @* @if (EditedInstanceConfiguration == null) { *@
<FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid"> @* <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid"> *@
<option value="" selected>Select which agent will run the instance...</option> @* <option value="" selected>Select which agent will run the instance...</option> *@
@foreach (var agent in form.AgentsByGuid.Values.Where(static agent => agent.IsOnline).OrderBy(static agent => agent.Name)) { @* @foreach (var agent in form.AgentsByGuid.Values.Where(static agent => agent.IsOnline).OrderBy(static agent => agent.Name)) { *@
@GetAgentOption(agent) @* @GetAgentOption(agent) *@
} @* } *@
</FormSelectInput> @* </FormSelectInput> *@
} @* } *@
else { @* else { *@
<FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid" disabled="true"> @* <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid" disabled="true"> *@
@if (form.SelectedAgentGuid is {} guid && form.AgentsByGuid.TryGetValue(guid, out var agent)) { @* @if (form.SelectedAgentGuid is {} guid && form.AgentsByGuid.TryGetValue(guid, out var agent)) { *@
@GetAgentOption(agent) @* @GetAgentOption(agent) *@
} @* } *@
</FormSelectInput> @* </FormSelectInput> *@
} @* } *@
</div> @* </div> *@
@* *@
<div class="col-xl-5 mb-3"> @* <div class="col-xl-5 mb-3"> *@
<FormTextInput Id="instance-name" Label="Instance Name" @bind-Value="form.InstanceName" /> @* <FormTextInput Id="instance-name" Label="Instance Name" @bind-Value="form.InstanceName" /> *@
</div> @* </div> *@
</div> @* </div> *@
@* *@
<div class="row"> @* <div class="row"> *@
<div class="col-sm-6 col-xl-2 mb-3"> @* <div class="col-sm-6 col-xl-2 mb-3"> *@
<FormSelectInput Id="instance-server-kind" Label="Server Software" @bind-Value="form.MinecraftServerKind"> @* <FormSelectInput Id="instance-server-kind" Label="Server Software" @bind-Value="form.MinecraftServerKind"> *@
@foreach (var kind in Enum.GetValues<MinecraftServerKind>()) { @* @foreach (var kind in Enum.GetValues<MinecraftServerKind>()) { *@
<option value="@kind">@kind</option> @* <option value="@kind">@kind</option> *@
} @* } *@
</FormSelectInput> @* </FormSelectInput> *@
</div> @* </div> *@
@* *@
<div class="col-sm-6 col-xl-3 mb-3"> @* <div class="col-sm-6 col-xl-3 mb-3"> *@
<FormSelectInput Id="instance-minecraft-version" Label="Minecraft Version" @bind-Value="form.MinecraftVersion"> @* <FormSelectInput Id="instance-minecraft-version" Label="Minecraft Version" @bind-Value="form.MinecraftVersion"> *@
<ChildContent> @* <ChildContent> *@
@foreach (var version in availableMinecraftVersions) { @* @foreach (var version in availableMinecraftVersions) { *@
<option value="@version.Id">@version.Id</option> @* <option value="@version.Id">@version.Id</option> *@
} @* } *@
</ChildContent> @* </ChildContent> *@
<GroupContent> @* <GroupContent> *@
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">@minecraftVersionType.ToNiceNamePlural()</button> @* <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">@minecraftVersionType.ToNiceNamePlural()</button> *@
<ul class="dropdown-menu dropdown-menu-end"> @* <ul class="dropdown-menu dropdown-menu-end"> *@
@foreach (var versionType in MinecraftVersionTypes.WithServerJars) { @* @foreach (var versionType in MinecraftVersionTypes.WithServerJars) { *@
<li> @* <li> *@
<button type="button" class="dropdown-item" @onclick="() => SetMinecraftVersionType(versionType)">@versionType.ToNiceNamePlural()</button> @* <button type="button" class="dropdown-item" @onclick="() => SetMinecraftVersionType(versionType)">@versionType.ToNiceNamePlural()</button> *@
</li> @* </li> *@
} @* } *@
</ul> @* </ul> *@
</GroupContent> @* </GroupContent> *@
</FormSelectInput> @* </FormSelectInput> *@
</div> @* </div> *@
@* *@
<div class="col-xl-3 mb-3"> @* <div class="col-xl-3 mb-3"> *@
<FormSelectInput Id="instance-java-runtime" Label="Java Runtime" @bind-Value="form.JavaRuntimeGuid" disabled="@(form.JavaRuntimesForSelectedAgent.IsEmpty)"> @* <FormSelectInput Id="instance-java-runtime" Label="Java Runtime" @bind-Value="form.JavaRuntimeGuid" disabled="@(form.JavaRuntimesForSelectedAgent.IsEmpty)"> *@
<option value="" selected>Select Java runtime...</option> @* <option value="" selected>Select Java runtime...</option> *@
@foreach (var (guid, runtime) in form.JavaRuntimesForSelectedAgent) { @* @foreach (var (guid, runtime) in form.JavaRuntimesForSelectedAgent) { *@
<option value="@guid">@runtime.DisplayName</option> @* <option value="@guid">@runtime.DisplayName</option> *@
} @* } *@
</FormSelectInput> @* </FormSelectInput> *@
</div> @* </div> *@
@* *@
@{ @* @{ *@
string? allowedServerPorts = selectedAgent?.AllowedServerPorts?.ToString(); @* string? allowedServerPorts = selectedAgent?.AllowedServerPorts?.ToString(); *@
string? allowedRconPorts = selectedAgent?.AllowedRconPorts?.ToString(); @* string? allowedRconPorts = selectedAgent?.AllowedRconPorts?.ToString(); *@
} @* } *@
<div class="col-sm-6 col-xl-2 mb-3"> @* <div class="col-sm-6 col-xl-2 mb-3"> *@
<FormNumberInput Id="instance-server-port" @bind-Value="form.ServerPort" min="0" max="65535"> @* <FormNumberInput Id="instance-server-port" @bind-Value="form.ServerPort" min="0" max="65535"> *@
<LabelFragment> @* <LabelFragment> *@
@if (string.IsNullOrEmpty(allowedServerPorts)) { @* @if (string.IsNullOrEmpty(allowedServerPorts)) { *@
<text>Server Port</text> @* <text>Server Port</text> *@
} @* } *@
else { @* else { *@
<text>Server Port <sup title="Allowed: @allowedServerPorts">[?]</sup></text> @* <text>Server Port <sup title="Allowed: @allowedServerPorts">[?]</sup></text> *@
} @* } *@
</LabelFragment> @* </LabelFragment> *@
</FormNumberInput> @* </FormNumberInput> *@
</div> @* </div> *@
@* *@
<div class="col-sm-6 col-xl-2 mb-3"> @* <div class="col-sm-6 col-xl-2 mb-3"> *@
<FormNumberInput Id="instance-rcon-port" @bind-Value="form.RconPort" min="0" max="65535"> @* <FormNumberInput Id="instance-rcon-port" @bind-Value="form.RconPort" min="0" max="65535"> *@
<LabelFragment> @* <LabelFragment> *@
@if (string.IsNullOrEmpty(allowedRconPorts)) { @* @if (string.IsNullOrEmpty(allowedRconPorts)) { *@
<text>Rcon Port</text> @* <text>Rcon Port</text> *@
} @* } *@
else { @* else { *@
<text>Rcon Port <sup title="Allowed: @allowedRconPorts">[?]</sup></text> @* <text>Rcon Port <sup title="Allowed: @allowedRconPorts">[?]</sup></text> *@
} @* } *@
</LabelFragment> @* </LabelFragment> *@
</FormNumberInput> @* </FormNumberInput> *@
</div> @* </div> *@
</div> @* </div> *@
@* *@
<div class="row"> @* <div class="row"> *@
<div class="col-xl-12 mb-3"> @* <div class="col-xl-12 mb-3"> *@
@{ @* @{ *@
const ushort MinimumMemoryUnits = 2; @* const ushort MinimumMemoryUnits = 2; *@
ushort maximumMemoryUnits = form.MaximumMemoryUnits; @* ushort maximumMemoryUnits = form.MaximumMemoryUnits; *@
double availableMemoryRatio = maximumMemoryUnits <= MinimumMemoryUnits ? 100.0 : 100.0 * (form.AvailableMemoryUnits - MinimumMemoryUnits) / (maximumMemoryUnits - MinimumMemoryUnits); @* double availableMemoryRatio = maximumMemoryUnits <= MinimumMemoryUnits ? 100.0 : 100.0 * (form.AvailableMemoryUnits - MinimumMemoryUnits) / (maximumMemoryUnits - MinimumMemoryUnits); *@
string memoryInputSplitVar = FormattableString.Invariant($"--range-split: {Math.Round(availableMemoryRatio, 2)}%"); @* string memoryInputSplitVar = FormattableString.Invariant($"--range-split: {Math.Round(availableMemoryRatio, 2)}%"); *@
} @* } *@
<FormNumberInput Id="instance-memory" Type="FormNumberInputType.Range" DebounceMillis="0" DisableTwoWayBinding="true" @bind-Value="form.MemoryUnits" min="@MinimumMemoryUnits" max="@maximumMemoryUnits" disabled="@(maximumMemoryUnits == 0)" class="form-range split-danger" style="@memoryInputSplitVar"> @* <FormNumberInput Id="instance-memory" Type="FormNumberInputType.Range" DebounceMillis="0" DisableTwoWayBinding="true" @bind-Value="form.MemoryUnits" min="@MinimumMemoryUnits" max="@maximumMemoryUnits" disabled="@(maximumMemoryUnits == 0)" class="form-range split-danger" style="@memoryInputSplitVar"> *@
<LabelFragment> @* <LabelFragment> *@
@if (maximumMemoryUnits == 0) { @* @if (maximumMemoryUnits == 0) { *@
<text>RAM</text> @* <text>RAM</text> *@
} @* } *@
else { @* else { *@
<text>RAM &bullet; <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent?.MaxMemory.InMegabytes) MB</code></text> @* <text>RAM &bullet; <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent?.MaxMemory.InMegabytes) MB</code></text> *@
} @* } *@
</LabelFragment> @* </LabelFragment> *@
</FormNumberInput> @* </FormNumberInput> *@
</div> @* </div> *@
</div> @* </div> *@
@* *@
<div class="row"> @* <div class="row"> *@
<div class="mb-3"> @* <div class="mb-3"> *@
<FormTextInput Id="instance-jvm-arguments" Type="FormTextInputType.Textarea" @bind-Value="form.JvmArguments" rows="4"> @* <FormTextInput Id="instance-jvm-arguments" Type="FormTextInputType.Textarea" @bind-Value="form.JvmArguments" rows="4"> *@
<LabelFragment> @* <LabelFragment> *@
JVM Arguments <span class="text-black-50">(one per line)</span> @* JVM Arguments <span class="text-black-50">(one per line)</span> *@
</LabelFragment> @* </LabelFragment> *@
</FormTextInput> @* </FormTextInput> *@
</div> @* </div> *@
</div> @* </div> *@
@* *@
<FormButtonSubmit Label="@(EditedInstanceConfiguration == null ? "Create Instance" : "Edit Instance")" class="btn btn-primary" disabled="@(!IsSubmittable)" /> @* <FormButtonSubmit Label="@(EditedInstanceConfiguration == null ? "Create Instance" : "Edit Instance")" class="btn btn-primary" disabled="@(!IsSubmittable)" /> *@
<FormSubmitError /> @* <FormSubmitError /> *@
</Form> @* </Form> *@
@* *@
@code { @* @code { *@
@* *@
[Parameter, EditorRequired] @* [Parameter, EditorRequired] *@
public InstanceConfiguration? EditedInstanceConfiguration { get; set; } @* public InstanceConfiguration? EditedInstanceConfiguration { get; set; } *@
@* *@
private ConfigureInstanceFormModel form = null!; @* private ConfigureInstanceFormModel form = null!; *@
@* *@
private MinecraftVersionType minecraftVersionType = MinecraftVersionType.Release; @* private MinecraftVersionType minecraftVersionType = MinecraftVersionType.Release; *@
private ImmutableArray<MinecraftVersion> availableMinecraftVersions = ImmutableArray<MinecraftVersion>.Empty; @* private ImmutableArray<MinecraftVersion> availableMinecraftVersions = ImmutableArray<MinecraftVersion>.Empty; *@
@* *@
private bool IsSubmittable => form.SelectedAgentGuid != null && !form.EditContext.GetValidationMessages(form.EditContext.Field(nameof(ConfigureInstanceFormModel.SelectedAgentGuid))).Any(); @* private bool IsSubmittable => form.SelectedAgentGuid != null && !form.EditContext.GetValidationMessages(form.EditContext.Field(nameof(ConfigureInstanceFormModel.SelectedAgentGuid))).Any(); *@
@* *@
private sealed class ConfigureInstanceFormModel : FormModel { @* private sealed class ConfigureInstanceFormModel : FormModel { *@
public ImmutableDictionary<Guid, Agent> AgentsByGuid { get; } @* public ImmutableDictionary<Guid, Agent> AgentsByGuid { get; } *@
private readonly ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> javaRuntimesByAgentGuid; @* private readonly ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> javaRuntimesByAgentGuid; *@
private readonly RamAllocationUnits? editedInstanceRamAllocation; @* private readonly RamAllocationUnits? editedInstanceRamAllocation; *@
@* *@
public ConfigureInstanceFormModel(AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, RamAllocationUnits? editedInstanceRamAllocation) { @* public ConfigureInstanceFormModel(AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, RamAllocationUnits? editedInstanceRamAllocation) { *@
this.AgentsByGuid = agentManager.GetAgents().ToImmutableDictionary(); @* this.AgentsByGuid = agentManager.GetAgents().ToImmutableDictionary(); *@
this.javaRuntimesByAgentGuid = agentJavaRuntimesManager.All; @* this.javaRuntimesByAgentGuid = agentJavaRuntimesManager.All; *@
this.editedInstanceRamAllocation = editedInstanceRamAllocation; @* this.editedInstanceRamAllocation = editedInstanceRamAllocation; *@
} @* } *@
@* *@
private bool TryGet<TValue>(ImmutableDictionary<Guid, TValue> dictionary, Guid? agentGuid, [MaybeNullWhen(false)] out TValue value) { @* private bool TryGet<TValue>(ImmutableDictionary<Guid, TValue> dictionary, Guid? agentGuid, [MaybeNullWhen(false)] out TValue value) { *@
if (agentGuid == null) { @* if (agentGuid == null) { *@
value = default; @* value = default; *@
return false; @* return false; *@
} @* } *@
else { @* else { *@
return dictionary.TryGetValue(agentGuid.Value, out value); @* return dictionary.TryGetValue(agentGuid.Value, out value); *@
} @* } *@
} @* } *@
@* *@
private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out Agent? agent) { @* private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out Agent? agent) { *@
return TryGet(AgentsByGuid, agentGuid, out agent); @* return TryGet(AgentsByGuid, agentGuid, out agent); *@
} @* } *@
@* *@
public Agent? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agent) ? agent : null; @* public Agent? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agent) ? agent : null; *@
@* *@
public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(javaRuntimesByAgentGuid, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty; @* public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(javaRuntimesByAgentGuid, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty; *@
@* *@
public ushort MaximumMemoryUnits => SelectedAgent?.MaxMemory.RawValue ?? 0; @* public ushort MaximumMemoryUnits => SelectedAgent?.MaxMemory.RawValue ?? 0; *@
public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits); @* public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits); *@
private ushort selectedMemoryUnits = 4; @* private ushort selectedMemoryUnits = 4; *@
@* *@
[Required(ErrorMessage = "You must select an agent.")] @* [Required(ErrorMessage = "You must select an agent.")] *@
public Guid? SelectedAgentGuid { get; set; } = null; @* public Guid? SelectedAgentGuid { get; set; } = null; *@
@* *@
[Required(ErrorMessage = "Instance name is required.")] @* [Required(ErrorMessage = "Instance name is required.")] *@
[StringLength(100, ErrorMessage = "Instance name must be at most 100 characters.")] @* [StringLength(100, ErrorMessage = "Instance name must be at most 100 characters.")] *@
public string InstanceName { get; set; } = string.Empty; @* public string InstanceName { get; set; } = string.Empty; *@
@* *@
[Range(minimum: 0, maximum: 65535, ErrorMessage = "Server port must be between 0 and 65535.")] @* [Range(minimum: 0, maximum: 65535, ErrorMessage = "Server port must be between 0 and 65535.")] *@
[ServerPortMustBeAllowed(ErrorMessage = "Server port is not allowed.")] @* [ServerPortMustBeAllowed(ErrorMessage = "Server port is not allowed.")] *@
public int ServerPort { get; set; } = 25565; @* public int ServerPort { get; set; } = 25565; *@
@* *@
[Range(minimum: 0, maximum: 65535, ErrorMessage = "Rcon port must be between 0 and 65535.")] @* [Range(minimum: 0, maximum: 65535, ErrorMessage = "Rcon port must be between 0 and 65535.")] *@
[RconPortMustBeAllowed(ErrorMessage = "Rcon port is not allowed.")] @* [RconPortMustBeAllowed(ErrorMessage = "Rcon port is not allowed.")] *@
[RconPortMustDifferFromServerPort(ErrorMessage = "Rcon port must not be the same as Server port.")] @* [RconPortMustDifferFromServerPort(ErrorMessage = "Rcon port must not be the same as Server port.")] *@
public int RconPort { get; set; } = 25575; @* public int RconPort { get; set; } = 25575; *@
@* *@
public MinecraftServerKind MinecraftServerKind { get; set; } = MinecraftServerKind.Vanilla; @* public MinecraftServerKind MinecraftServerKind { get; set; } = MinecraftServerKind.Vanilla; *@
@* *@
[Required(ErrorMessage = "You must select a Java runtime.")] @* [Required(ErrorMessage = "You must select a Java runtime.")] *@
public Guid? JavaRuntimeGuid { get; set; } @* public Guid? JavaRuntimeGuid { get; set; } *@
@* *@
[Required(ErrorMessage = "You must select a Minecraft version.")] @* [Required(ErrorMessage = "You must select a Minecraft version.")] *@
public string MinecraftVersion { get; set; } = string.Empty; @* public string MinecraftVersion { get; set; } = string.Empty; *@
@* *@
[Range(minimum: 0, maximum: RamAllocationUnits.MaximumUnits, ErrorMessage = "Memory is out of range.")] @* [Range(minimum: 0, maximum: RamAllocationUnits.MaximumUnits, ErrorMessage = "Memory is out of range.")] *@
public ushort MemoryUnits { @* public ushort MemoryUnits { *@
get => Math.Min(selectedMemoryUnits, MaximumMemoryUnits); @* get => Math.Min(selectedMemoryUnits, MaximumMemoryUnits); *@
set => selectedMemoryUnits = value; @* set => selectedMemoryUnits = value; *@
} @* } *@
@* *@
public RamAllocationUnits? MemoryAllocation => new RamAllocationUnits(MemoryUnits); @* public RamAllocationUnits? MemoryAllocation => new RamAllocationUnits(MemoryUnits); *@
@* *@
[JvmArgumentsMustBeValid(ErrorMessage = "JVM arguments are not valid.")] @* [JvmArgumentsMustBeValid(ErrorMessage = "JVM arguments are not valid.")] *@
public string JvmArguments { get; set; } = string.Empty; @* public string JvmArguments { get; set; } = string.Empty; *@
@* *@
public sealed class ServerPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> { @* public sealed class ServerPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> { *@
protected override string FieldName => nameof(ServerPort); @* protected override string FieldName => nameof(ServerPort); *@
protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedServerPorts?.Contains((ushort) value) == true; @* protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedServerPorts?.Contains((ushort) value) == true; *@
} @* } *@
@* *@
public sealed class RconPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> { @* public sealed class RconPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> { *@
protected override string FieldName => nameof(RconPort); @* protected override string FieldName => nameof(RconPort); *@
protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedRconPorts?.Contains((ushort) value) == true; @* protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedRconPorts?.Contains((ushort) value) == true; *@
} @* } *@
@* *@
public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int?> { @* public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int?> { *@
protected override string FieldName => nameof(RconPort); @* protected override string FieldName => nameof(RconPort); *@
protected override bool IsValid(ConfigureInstanceFormModel model, int? value) => value != model.ServerPort; @* protected override bool IsValid(ConfigureInstanceFormModel model, int? value) => value != model.ServerPort; *@
} @* } *@
@* *@
public sealed class JvmArgumentsMustBeValidAttribute : FormCustomValidationAttribute<ConfigureInstanceFormModel, string> { @* public sealed class JvmArgumentsMustBeValidAttribute : FormCustomValidationAttribute<ConfigureInstanceFormModel, string> { *@
protected override string FieldName => nameof(JvmArguments); @* protected override string FieldName => nameof(JvmArguments); *@
@* *@
protected override ValidationResult? Validate(ConfigureInstanceFormModel model, string value) { @* protected override ValidationResult? Validate(ConfigureInstanceFormModel model, string value) { *@
var error = JvmArgumentsHelper.Validate(value); @* var error = JvmArgumentsHelper.Validate(value); *@
return error == null ? ValidationResult.Success : new ValidationResult(error.ToSentence()); @* return error == null ? ValidationResult.Success : new ValidationResult(error.ToSentence()); *@
} @* } *@
} @* } *@
} @* } *@
@* *@
protected override void OnInitialized() { @* protected override void OnInitialized() { *@
form = new ConfigureInstanceFormModel(AgentManager, AgentJavaRuntimesManager, EditedInstanceConfiguration?.MemoryAllocation); @* form = new ConfigureInstanceFormModel(AgentManager, AgentJavaRuntimesManager, EditedInstanceConfiguration?.MemoryAllocation); *@
@* *@
if (EditedInstanceConfiguration != null) { @* if (EditedInstanceConfiguration != null) { *@
form.SelectedAgentGuid = EditedInstanceConfiguration.AgentGuid; @* form.SelectedAgentGuid = EditedInstanceConfiguration.AgentGuid; *@
form.InstanceName = EditedInstanceConfiguration.InstanceName; @* form.InstanceName = EditedInstanceConfiguration.InstanceName; *@
form.ServerPort = EditedInstanceConfiguration.ServerPort; @* form.ServerPort = EditedInstanceConfiguration.ServerPort; *@
form.RconPort = EditedInstanceConfiguration.RconPort; @* form.RconPort = EditedInstanceConfiguration.RconPort; *@
form.MinecraftVersion = EditedInstanceConfiguration.MinecraftVersion; @* form.MinecraftVersion = EditedInstanceConfiguration.MinecraftVersion; *@
form.MinecraftServerKind = EditedInstanceConfiguration.MinecraftServerKind; @* form.MinecraftServerKind = EditedInstanceConfiguration.MinecraftServerKind; *@
form.MemoryUnits = EditedInstanceConfiguration.MemoryAllocation.RawValue; @* form.MemoryUnits = EditedInstanceConfiguration.MemoryAllocation.RawValue; *@
form.JavaRuntimeGuid = EditedInstanceConfiguration.JavaRuntimeGuid; @* form.JavaRuntimeGuid = EditedInstanceConfiguration.JavaRuntimeGuid; *@
form.JvmArguments = JvmArgumentsHelper.Join(EditedInstanceConfiguration.JvmArguments); @* form.JvmArguments = JvmArgumentsHelper.Join(EditedInstanceConfiguration.JvmArguments); *@
} @* } *@
@* *@
form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.MemoryUnits)); @* form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.MemoryUnits)); *@
form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.JavaRuntimeGuid)); @* form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.JavaRuntimeGuid)); *@
form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.ServerPort)); @* form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.ServerPort)); *@
form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.RconPort)); @* form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.RconPort)); *@
form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.ServerPort), revalidated: nameof(ConfigureInstanceFormModel.RconPort)); @* form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.ServerPort), revalidated: nameof(ConfigureInstanceFormModel.RconPort)); *@
} @* } *@
@* *@
protected override async Task OnInitializedAsync() { @* protected override async Task OnInitializedAsync() { *@
if (EditedInstanceConfiguration != null) { @* if (EditedInstanceConfiguration != null) { *@
var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None); @* var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None); *@
minecraftVersionType = allMinecraftVersions.FirstOrDefault(version => version.Id == EditedInstanceConfiguration.MinecraftVersion)?.Type ?? minecraftVersionType; @* minecraftVersionType = allMinecraftVersions.FirstOrDefault(version => version.Id == EditedInstanceConfiguration.MinecraftVersion)?.Type ?? minecraftVersionType; *@
} @* } *@
@* *@
await SetMinecraftVersionType(minecraftVersionType); @* await SetMinecraftVersionType(minecraftVersionType); *@
} @* } *@
@* *@
private async Task SetMinecraftVersionType(MinecraftVersionType type) { @* private async Task SetMinecraftVersionType(MinecraftVersionType type) { *@
minecraftVersionType = type; @* minecraftVersionType = type; *@
@* *@
var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None); @* var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None); *@
availableMinecraftVersions = allMinecraftVersions.Where(version => version.Type == type).ToImmutableArray(); @* availableMinecraftVersions = allMinecraftVersions.Where(version => version.Type == type).ToImmutableArray(); *@
@* *@
if (!availableMinecraftVersions.IsEmpty && !availableMinecraftVersions.Any(version => version.Id == form.MinecraftVersion)) { @* if (!availableMinecraftVersions.IsEmpty && !availableMinecraftVersions.Any(version => version.Id == form.MinecraftVersion)) { *@
form.MinecraftVersion = availableMinecraftVersions[0].Id; @* form.MinecraftVersion = availableMinecraftVersions[0].Id; *@
} @* } *@
} @* } *@
@* *@
private async Task AddOrEditInstance(EditContext context) { @* private async Task AddOrEditInstance(EditContext context) { *@
var selectedAgent = form.SelectedAgent; @* var selectedAgent = form.SelectedAgent; *@
if (selectedAgent == null) { @* if (selectedAgent == null) { *@
return; @* return; *@
} @* } *@
@* *@
await form.SubmitModel.StartSubmitting(); @* await form.SubmitModel.StartSubmitting(); *@
@* *@
var instance = new InstanceConfiguration( @* var instance = new InstanceConfiguration( *@
EditedInstanceConfiguration?.AgentGuid ?? selectedAgent.Guid, @* EditedInstanceConfiguration?.AgentGuid ?? selectedAgent.Guid, *@
EditedInstanceConfiguration?.InstanceGuid ?? Guid.NewGuid(), @* EditedInstanceConfiguration?.InstanceGuid ?? Guid.NewGuid(), *@
form.InstanceName, @* form.InstanceName, *@
(ushort) form.ServerPort, @* (ushort) form.ServerPort, *@
(ushort) form.RconPort, @* (ushort) form.RconPort, *@
form.MinecraftVersion, @* form.MinecraftVersion, *@
form.MinecraftServerKind, @* form.MinecraftServerKind, *@
form.MemoryAllocation ?? RamAllocationUnits.Zero, @* form.MemoryAllocation ?? RamAllocationUnits.Zero, *@
form.JavaRuntimeGuid.GetValueOrDefault(), @* form.JavaRuntimeGuid.GetValueOrDefault(), *@
JvmArgumentsHelper.Split(form.JvmArguments) @* JvmArgumentsHelper.Split(form.JvmArguments) *@
); @* ); *@
@* *@
var result = await InstanceManager.AddOrEditInstance(instance); @* var result = await InstanceManager.AddOrEditInstance(instance); *@
if (result.Is(AddOrEditInstanceResult.Success)) { @* if (result.Is(AddOrEditInstanceResult.Success)) { *@
await (EditedInstanceConfiguration == null ? AuditLog.AddInstanceCreatedEvent(instance.InstanceGuid) : AuditLog.AddInstanceEditedEvent(instance.InstanceGuid)); @* await (EditedInstanceConfiguration == null ? AuditLog.AddInstanceCreatedEvent(instance.InstanceGuid) : AuditLog.AddInstanceEditedEvent(instance.InstanceGuid)); *@
Nav.NavigateTo("instances/" + instance.InstanceGuid); @* Nav.NavigateTo("instances/" + instance.InstanceGuid); *@
} @* } *@
else { @* else { *@
form.SubmitModel.StopSubmitting(result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence)); @* form.SubmitModel.StopSubmitting(result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence)); *@
} @* } *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,53 +1,53 @@
@using Phantom.Web.Services.Instances @* @using Phantom.Web.Services.Instances *@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@using Phantom.Common.Data.Replies @* @using Phantom.Common.Data.Replies *@
@inherits PhantomComponent @* @inherits PhantomComponent *@
@inject InstanceManager InstanceManager @* @inject InstanceManager InstanceManager *@
@* *@
<Form Model="form" OnSubmit="ExecuteCommand"> @* <Form Model="form" OnSubmit="ExecuteCommand"> *@
<label for="command-input" class="form-label">Instance Name</label> @* <label for="command-input" class="form-label">Instance Name</label> *@
<div class="input-group flex-nowrap"> @* <div class="input-group flex-nowrap"> *@
<span class="input-group-text" style="padding-top: 0.3rem;">/</span> @* <span class="input-group-text" style="padding-top: 0.3rem;">/</span> *@
<input id="command-input" class="form-control" type="text" placeholder="command" @bind="form.Command" @bind:event="oninput" disabled="@(Disabled || form.SubmitModel.IsSubmitting)" @ref="commandInputElement" /> @* <input id="command-input" class="form-control" type="text" placeholder="command" @bind="form.Command" @bind:event="oninput" disabled="@(Disabled || form.SubmitModel.IsSubmitting)" @ref="commandInputElement" /> *@
<FormButtonSubmit Label="Execute" class="btn btn-primary" disabled="@(Disabled || string.IsNullOrWhiteSpace(form.Command))" /> @* <FormButtonSubmit Label="Execute" class="btn btn-primary" disabled="@(Disabled || string.IsNullOrWhiteSpace(form.Command))" /> *@
</div> @* </div> *@
<FormSubmitError /> @* <FormSubmitError /> *@
</Form> @* </Form> *@
@* *@
@code { @* @code { *@
@* *@
[Parameter] @* [Parameter] *@
public Guid InstanceGuid { get; set; } @* public Guid InstanceGuid { get; set; } *@
@* *@
[Parameter] @* [Parameter] *@
public bool Disabled { get; set; } @* public bool Disabled { get; set; } *@
@* *@
private readonly SendCommandFormModel form = new (); @* private readonly SendCommandFormModel form = new (); *@
@* *@
private sealed class SendCommandFormModel : FormModel { @* private sealed class SendCommandFormModel : FormModel { *@
public string Command { get; set; } = string.Empty; @* public string Command { get; set; } = string.Empty; *@
} @* } *@
@* *@
private ElementReference commandInputElement; @* private ElementReference commandInputElement; *@
@* *@
private async Task ExecuteCommand(EditContext context) { @* private async Task ExecuteCommand(EditContext context) { *@
await form.SubmitModel.StartSubmitting(); @* await form.SubmitModel.StartSubmitting(); *@
@* *@
if (!await CheckPermission(Permission.ControlInstances)) { @* if (!await CheckPermission(Permission.ControlInstances)) { *@
form.SubmitModel.StopSubmitting("You do not have permission to execute commands."); @* form.SubmitModel.StopSubmitting("You do not have permission to execute commands."); *@
return; @* return; *@
} @* } *@
@* *@
var result = await InstanceManager.SendCommand(InstanceGuid, form.Command); @* var result = await InstanceManager.SendCommand(InstanceGuid, form.Command); *@
if (result.Is(SendCommandToInstanceResult.Success)) { @* if (result.Is(SendCommandToInstanceResult.Success)) { *@
form.Command = string.Empty; @* form.Command = string.Empty; *@
form.SubmitModel.StopSubmitting(); @* form.SubmitModel.StopSubmitting(); *@
} @* } *@
else { @* else { *@
form.SubmitModel.StopSubmitting(result.ToSentence(Messages.ToSentence)); @* form.SubmitModel.StopSubmitting(result.ToSentence(Messages.ToSentence)); *@
} @* } *@
@* *@
await commandInputElement.FocusAsync(preventScroll: true); @* await commandInputElement.FocusAsync(preventScroll: true); *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,71 +1,71 @@
@inherits PhantomComponent @* @inherits PhantomComponent *@
@using Phantom.Utils.Collections @* @using Phantom.Utils.Collections *@
@using System.Diagnostics @* @using System.Diagnostics *@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@implements IDisposable @* @implements IDisposable *@
@inject IJSRuntime Js; @* @inject IJSRuntime Js; *@
@inject InstanceLogManager InstanceLogManager @* @inject InstanceLogManager InstanceLogManager *@
@* *@
<div id="log" class="font-monospace mb-3"> @* <div id="log" class="font-monospace mb-3"> *@
@foreach (var line in instanceLogs.EnumerateLast(uint.MaxValue)) { @* @foreach (var line in instanceLogs.EnumerateLast(uint.MaxValue)) { *@
<p>@(new MarkupString(line))</p> @* <p>@(new MarkupString(line))</p> *@
} @* } *@
</div> @* </div> *@
@* *@
@code { @* @code { *@
@* *@
[Parameter, EditorRequired] @* [Parameter, EditorRequired] *@
public Guid InstanceGuid { get; set; } @* public Guid InstanceGuid { get; set; } *@
@* *@
private IJSObjectReference? PageJs { get; set; } @* private IJSObjectReference? PageJs { get; set; } *@
@* *@
private EventSubscribers<RingBuffer<string>> instanceLogsSubs = null!; @* private EventSubscribers<RingBuffer<string>> instanceLogsSubs = null!; *@
private RingBuffer<string> instanceLogs = null!; @* private RingBuffer<string> instanceLogs = null!; *@
@* *@
private readonly Stopwatch recheckPermissionsStopwatch = Stopwatch.StartNew(); @* private readonly Stopwatch recheckPermissionsStopwatch = Stopwatch.StartNew(); *@
@* *@
protected override void OnInitialized() { @* protected override void OnInitialized() { *@
instanceLogsSubs = InstanceLogManager.GetSubs(InstanceGuid); @* instanceLogsSubs = InstanceLogManager.GetSubs(InstanceGuid); *@
instanceLogsSubs.Subscribe(this, buffer => { @* instanceLogsSubs.Subscribe(this, buffer => { *@
instanceLogs = buffer; @* instanceLogs = buffer; *@
InvokeAsyncChecked(RefreshLog); @* InvokeAsyncChecked(RefreshLog); *@
}); @* }); *@
} @* } *@
@* *@
protected override async Task OnAfterRenderAsync(bool firstRender) { @* protected override async Task OnAfterRenderAsync(bool firstRender) { *@
if (firstRender) { @* if (firstRender) { *@
PageJs = await Js.InvokeAsync<IJSObjectReference>("import", "./Shared/InstanceLog.razor.js"); @* PageJs = await Js.InvokeAsync<IJSObjectReference>("import", "./Shared/InstanceLog.razor.js"); *@
await RecheckPermissions(); @* await RecheckPermissions(); *@
StateHasChanged(); @* StateHasChanged(); *@
@* *@
await PageJs.InvokeVoidAsync("initLog"); @* await PageJs.InvokeVoidAsync("initLog"); *@
} @* } *@
} @* } *@
@* *@
private async Task RefreshLog() { @* private async Task RefreshLog() { *@
if (recheckPermissionsStopwatch.Elapsed > TimeSpan.FromSeconds(2)) { @* if (recheckPermissionsStopwatch.Elapsed > TimeSpan.FromSeconds(2)) { *@
await RecheckPermissions(); @* await RecheckPermissions(); *@
} @* } *@
@* *@
StateHasChanged(); @* StateHasChanged(); *@
@* *@
if (PageJs != null) { @* if (PageJs != null) { *@
await PageJs.InvokeVoidAsync("scrollLog"); @* await PageJs.InvokeVoidAsync("scrollLog"); *@
} @* } *@
} @* } *@
@* *@
private async Task RecheckPermissions() { @* private async Task RecheckPermissions() { *@
recheckPermissionsStopwatch.Restart(); @* recheckPermissionsStopwatch.Restart(); *@
@* *@
if (!await CheckPermission(Permission.ViewInstanceLogs)) { @* if (!await CheckPermission(Permission.ViewInstanceLogs)) { *@
await Task.Yield(); @* await Task.Yield(); *@
Dispose(); @* Dispose(); *@
instanceLogs = new RingBuffer<string>(0); @* instanceLogs = new RingBuffer<string>(0); *@
} @* } *@
} @* } *@
@* *@
public void Dispose() { @* public void Dispose() { *@
instanceLogsSubs.Unsubscribe(this); @* instanceLogsSubs.Unsubscribe(this); *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,70 +1,70 @@
@using Phantom.Web.Services.Instances @* @using Phantom.Web.Services.Instances *@
@using System.ComponentModel.DataAnnotations @* @using System.ComponentModel.DataAnnotations *@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@using Phantom.Common.Data.Minecraft @* @using Phantom.Common.Data.Minecraft *@
@using Phantom.Common.Data.Replies @* @using Phantom.Common.Data.Replies *@
@inherits PhantomComponent @* @inherits PhantomComponent *@
@inject IJSRuntime Js; @* @inject IJSRuntime Js; *@
@inject InstanceManager InstanceManager; @* @inject InstanceManager InstanceManager; *@
@inject AuditLog AuditLog @* @inject AuditLog AuditLog *@
@* *@
<Form Model="form" OnSubmit="StopInstance"> @* <Form Model="form" OnSubmit="StopInstance"> *@
<Modal Id="@ModalId" TitleText="Stop Instance"> @* <Modal Id="@ModalId" TitleText="Stop Instance"> *@
<Body> @* <Body> *@
<FormSelectInput Id="stop-in-seconds" Label="Stop In..." @bind-Value="form.StopInSeconds"> @* <FormSelectInput Id="stop-in-seconds" Label="Stop In..." @bind-Value="form.StopInSeconds"> *@
<option value="0">Immediately</option> @* <option value="0">Immediately</option> *@
<option value="10">10 Seconds</option> @* <option value="10">10 Seconds</option> *@
<option value="30">30 Seconds</option> @* <option value="30">30 Seconds</option> *@
<option value="60">1 Minute</option> @* <option value="60">1 Minute</option> *@
<option value="120">2 Minutes</option> @* <option value="120">2 Minutes</option> *@
<option value="180">3 Minutes</option> @* <option value="180">3 Minutes</option> *@
<option value="240">4 Minutes</option> @* <option value="240">4 Minutes</option> *@
<option value="300">5 Minutes</option> @* <option value="300">5 Minutes</option> *@
</FormSelectInput> @* </FormSelectInput> *@
</Body> @* </Body> *@
<Footer> @* <Footer> *@
<FormSubmitError /> @* <FormSubmitError /> *@
<FormButtonSubmit Label="Stop Instance" class="btn btn-danger" disabled="@Disabled" /> @* <FormButtonSubmit Label="Stop Instance" class="btn btn-danger" disabled="@Disabled" /> *@
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> @* <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> *@
</Footer> @* </Footer> *@
</Modal> @* </Modal> *@
</Form> @* </Form> *@
@* *@
@code { @* @code { *@
@* *@
[Parameter, EditorRequired] @* [Parameter, EditorRequired] *@
public Guid InstanceGuid { get; set; } @* public Guid InstanceGuid { get; set; } *@
@* *@
[Parameter, EditorRequired] @* [Parameter, EditorRequired] *@
public string ModalId { get; set; } = string.Empty; @* public string ModalId { get; set; } = string.Empty; *@
@* *@
[Parameter] @* [Parameter] *@
public bool Disabled { get; set; } @* public bool Disabled { get; set; } *@
@* *@
private readonly StopInstanceFormModel form = new (); @* private readonly StopInstanceFormModel form = new (); *@
@* *@
private sealed class StopInstanceFormModel : FormModel { @* private sealed class StopInstanceFormModel : FormModel { *@
[Range(minimum: 0, maximum: 300, ErrorMessage = "Stop delay must be between 0 and 300 seconds.")] @* [Range(minimum: 0, maximum: 300, ErrorMessage = "Stop delay must be between 0 and 300 seconds.")] *@
public ushort StopInSeconds { get; set; } = 0; @* public ushort StopInSeconds { get; set; } = 0; *@
} @* } *@
@* *@
private async Task StopInstance(EditContext context) { @* private async Task StopInstance(EditContext context) { *@
await form.SubmitModel.StartSubmitting(); @* await form.SubmitModel.StartSubmitting(); *@
@* *@
if (!await CheckPermission(Permission.ControlInstances)) { @* if (!await CheckPermission(Permission.ControlInstances)) { *@
form.SubmitModel.StopSubmitting("You do not have permission to stop instances."); @* form.SubmitModel.StopSubmitting("You do not have permission to stop instances."); *@
return; @* return; *@
} @* } *@
@* *@
var result = await InstanceManager.StopInstance(InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds)); @* var result = await InstanceManager.StopInstance(InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds)); *@
if (result.Is(StopInstanceResult.StopInitiated)) { @* if (result.Is(StopInstanceResult.StopInitiated)) { *@
await AuditLog.AddInstanceStoppedEvent(InstanceGuid, form.StopInSeconds); @* await AuditLog.AddInstanceStoppedEvent(InstanceGuid, form.StopInSeconds); *@
await Js.InvokeVoidAsync("closeModal", ModalId); @* await Js.InvokeVoidAsync("closeModal", ModalId); *@
form.SubmitModel.StopSubmitting(); @* form.SubmitModel.StopSubmitting(); *@
} @* } *@
else { @* else { *@
form.SubmitModel.StopSubmitting(result.ToSentence(Messages.ToSentence)); @* form.SubmitModel.StopSubmitting(result.ToSentence(Messages.ToSentence)); *@
} @* } *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,72 +1,72 @@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@using Phantom.Utils.Tasks @* @using Phantom.Utils.Tasks *@
@using System.ComponentModel.DataAnnotations @* @using System.ComponentModel.DataAnnotations *@
@inherits PhantomComponent @* @inherits PhantomComponent *@
@inject IJSRuntime Js; @* @inject IJSRuntime Js; *@
@* *@
<Form Model="form" OnSubmit="AddUser"> @* <Form Model="form" OnSubmit="AddUser"> *@
<Modal Id="@ModalId" TitleText="Add User"> @* <Modal Id="@ModalId" TitleText="Add User"> *@
<Body> @* <Body> *@
@* *@
<div class="row"> @* <div class="row"> *@
<div class="mb-3"> @* <div class="mb-3"> *@
<FormTextInput Id="account-username" Label="Username" @bind-Value="form.Username" autocomplete="off" /> @* <FormTextInput Id="account-username" Label="Username" @bind-Value="form.Username" autocomplete="off" /> *@
</div> @* </div> *@
</div> @* </div> *@
@* *@
<div class="row"> @* <div class="row"> *@
<div class="mb-3"> @* <div class="mb-3"> *@
<FormTextInput Id="account-password" Label="Password" Type="FormTextInputType.Password" autocomplete="new-password" @bind-Value="form.Password" /> @* <FormTextInput Id="account-password" Label="Password" Type="FormTextInputType.Password" autocomplete="new-password" @bind-Value="form.Password" /> *@
</div> @* </div> *@
</div> @* </div> *@
@* *@
</Body> @* </Body> *@
<Footer> @* <Footer> *@
<FormSubmitError /> @* <FormSubmitError /> *@
<FormButtonSubmit Label="Add User" class="btn btn-primary" /> @* <FormButtonSubmit Label="Add User" class="btn btn-primary" /> *@
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> @* <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> *@
</Footer> @* </Footer> *@
</Modal> @* </Modal> *@
</Form> @* </Form> *@
@* *@
@code { @* @code { *@
@* *@
[Parameter, EditorRequired] @* [Parameter, EditorRequired] *@
public string ModalId { get; set; } = string.Empty; @* public string ModalId { get; set; } = string.Empty; *@
@* *@
[Parameter] @* [Parameter] *@
public EventCallback<UserInfo> UserAdded { get; set; } @* public EventCallback<UserInfo> UserAdded { get; set; } *@
@* *@
private readonly AddUserFormModel form = new(); @* private readonly AddUserFormModel form = new(); *@
@* *@
private sealed class AddUserFormModel : FormModel { @* private sealed class AddUserFormModel : FormModel { *@
[Required] @* [Required] *@
public string Username { get; set; } = string.Empty; @* public string Username { get; set; } = string.Empty; *@
@* *@
[Required] @* [Required] *@
public string Password { get; set; } = string.Empty; @* public string Password { get; set; } = string.Empty; *@
} @* } *@
@* *@
private async Task AddUser(EditContext context) { @* private async Task AddUser(EditContext context) { *@
await form.SubmitModel.StartSubmitting(); @* await form.SubmitModel.StartSubmitting(); *@
@* *@
if (!await CheckPermission(Permission.EditUsers)) { @* if (!await CheckPermission(Permission.EditUsers)) { *@
form.SubmitModel.StopSubmitting("You do not have permission to add users."); @* form.SubmitModel.StopSubmitting("You do not have permission to add users."); *@
return; @* return; *@
} @* } *@
@* *@
switch (await UserManager.CreateUser(form.Username, form.Password)) { @* switch (await UserManager.CreateUser(form.Username, form.Password)) { *@
case Result<UserInfo, AddUserError>.Ok ok: @* case Result<UserInfo, AddUserError>.Ok ok: *@
await AuditLog.AddUserCreatedEvent(ok.Value); @* await AuditLog.AddUserCreatedEvent(ok.Value); *@
await UserAdded.InvokeAsync(ok.Value); @* await UserAdded.InvokeAsync(ok.Value); *@
await Js.InvokeVoidAsync("closeModal", ModalId); @* await Js.InvokeVoidAsync("closeModal", ModalId); *@
form.SubmitModel.StopSubmitting(); @* form.SubmitModel.StopSubmitting(); *@
break; @* break; *@
@* *@
case Result<UserInfo, AddUserError>.Fail fail: @* case Result<UserInfo, AddUserError>.Fail fail: *@
form.SubmitModel.StopSubmitting(fail.Error.ToSentences("\n")); @* form.SubmitModel.StopSubmitting(fail.Error.ToSentences("\n")); *@
break; @* break; *@
} @* } *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,37 +1,37 @@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@inherits UserEditDialogBase @* @inherits UserEditDialogBase *@
@inject UserManager UserManager @* @inject UserManager UserManager *@
@inject AuditLog AuditLog @* @inject AuditLog AuditLog *@
@* *@
<Modal Id="@ModalId" TitleText="Delete User"> @* <Modal Id="@ModalId" TitleText="Delete User"> *@
<Body> @* <Body> *@
You are about to delete the user <strong class="fw-semibold">@EditedUserName</strong>.<br> @* You are about to delete the user <strong class="fw-semibold">@EditedUserName</strong>.<br> *@
This action cannot be undone. @* This action cannot be undone. *@
</Body> @* </Body> *@
<Footer> @* <Footer> *@
<FormSubmitError Model="SubmitModel" /> @* <FormSubmitError Model="SubmitModel" /> *@
<FormButtonSubmit Model="SubmitModel" Label="Delete User" type="button" class="btn btn-danger" @onclick="Submit" /> @* <FormButtonSubmit Model="SubmitModel" Label="Delete User" type="button" class="btn btn-danger" @onclick="Submit" /> *@
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @onclick="OnClosed">Cancel</button> @* <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @onclick="OnClosed">Cancel</button> *@
</Footer> @* </Footer> *@
</Modal> @* </Modal> *@
@* *@
@code { @* @code { *@
@* *@
protected override async Task DoEdit(UserInfo user) { @* protected override async Task DoEdit(UserInfo user) { *@
switch (await UserManager.DeleteByGuid(user.Guid)) { @* switch (await UserManager.DeleteByGuid(user.Guid)) { *@
case DeleteUserResult.Deleted: @* case DeleteUserResult.Deleted: *@
await AuditLog.AddUserDeletedEvent(user); @* await AuditLog.AddUserDeletedEvent(user); *@
await OnEditSuccess(); @* await OnEditSuccess(); *@
break; @* break; *@
@* *@
case DeleteUserResult.NotFound: @* case DeleteUserResult.NotFound: *@
await OnEditSuccess(); @* await OnEditSuccess(); *@
break; @* break; *@
@* *@
case DeleteUserResult.Failed: @* case DeleteUserResult.Failed: *@
OnEditFailure("Could not delete user."); @* OnEditFailure("Could not delete user."); *@
break; @* break; *@
} @* } *@
} @* } *@
@* *@
} @* } *@

View File

@ -1,76 +1,76 @@
@using Phantom.Common.Data.Web.Users @* @using Phantom.Common.Data.Web.Users *@
@inherits UserEditDialogBase @* @inherits UserEditDialogBase *@
@* *@
<Modal Id="@ModalId" TitleText="Manage User Roles"> @* <Modal Id="@ModalId" TitleText="Manage User Roles"> *@
<Body> @* <Body> *@
Roles for user: <strong class="fw-semibold">@EditedUserName</strong><br> @* Roles for user: <strong class="fw-semibold">@EditedUserName</strong><br> *@
@for (var index = 0; index < items.Count; index++) { @* @for (var index = 0; index < items.Count; index++) { *@
var item = items[index]; @* var item = items[index]; *@
<div class="mt-1"> @* <div class="mt-1"> *@
<input id="role-@index" type="checkbox" class="form-check-input" @bind="@item.Checked" /> @* <input id="role-@index" type="checkbox" class="form-check-input" @bind="@item.Checked" /> *@
<label for="role-@index" class="form-check-label">@item.Role.Name</label> @* <label for="role-@index" class="form-check-label">@item.Role.Name</label> *@
</div> @* </div> *@
} @* } *@
</Body> @* </Body> *@
<Footer> @* <Footer> *@
<FormSubmitError Model="SubmitModel" /> @* <FormSubmitError Model="SubmitModel" /> *@
<FormButtonSubmit Model="SubmitModel" Label="Save Roles" type="button" class="btn btn-success" @onclick="Submit" /> @* <FormButtonSubmit Model="SubmitModel" Label="Save Roles" type="button" class="btn btn-success" @onclick="Submit" /> *@
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @onclick="OnClosed">Cancel</button> @* <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @onclick="OnClosed">Cancel</button> *@
</Footer> @* </Footer> *@
</Modal> @* </Modal> *@
@* *@
@code { @* @code { *@
@* *@
private List<RoleItem> items = new(); @* private List<RoleItem> items = new(); *@
@* *@
protected override async Task BeforeShown(UserInfo user) { @* protected override async Task BeforeShown(UserInfo user) { *@
var userRoles = await UserRoleManager.GetUserRoleGuids(user); @* var userRoles = await UserRoleManager.GetUserRoleGuids(user); *@
var allRoles = await RoleManager.GetAll(); @* var allRoles = await RoleManager.GetAll(); *@
this.items = allRoles.Select(role => new RoleItem(role, userRoles.Contains(role.RoleGuid))).ToList(); @* this.items = allRoles.Select(role => new RoleItem(role, userRoles.Contains(role.RoleGuid))).ToList(); *@
} @* } *@
@* *@
protected override async Task DoEdit(UserInfo user) { @* protected override async Task DoEdit(UserInfo user) { *@
var userRoles = await UserRoleManager.GetUserRoleGuids(user); @* var userRoles = await UserRoleManager.GetUserRoleGuids(user); *@
var addedToRoles = new List<string>(); @* var addedToRoles = new List<string>(); *@
var removedFromRoles = new List<string>(); @* var removedFromRoles = new List<string>(); *@
var errors = new List<string>(); @* var errors = new List<string>(); *@
@* *@
foreach (var item in items) { @* foreach (var item in items) { *@
var shouldHaveRole = item.Checked; @* var shouldHaveRole = item.Checked; *@
if (shouldHaveRole == userRoles.Contains(item.Role.Guid)) { @* if (shouldHaveRole == userRoles.Contains(item.Role.Guid)) { *@
continue; @* continue; *@
} @* } *@
@* *@
bool success = shouldHaveRole ? await UserRoleManager.Add(user, item.Role) : await UserRoleManager.Remove(user, item.Role); @* bool success = shouldHaveRole ? await UserRoleManager.Add(user, item.Role) : await UserRoleManager.Remove(user, item.Role); *@
if (success) { @* if (success) { *@
var modifiedList = shouldHaveRole ? addedToRoles : removedFromRoles; @* var modifiedList = shouldHaveRole ? addedToRoles : removedFromRoles; *@
modifiedList.Add(item.Role.Name); @* modifiedList.Add(item.Role.Name); *@
} @* } *@
else if (shouldHaveRole) { @* else if (shouldHaveRole) { *@
errors.Add("Could not add role " + item.Role.Name + " to user."); @* errors.Add("Could not add role " + item.Role.Name + " to user."); *@
} @* } *@
else { @* else { *@
errors.Add("Could not remove role " + item.Role.Name + " from user."); @* errors.Add("Could not remove role " + item.Role.Name + " from user."); *@
} @* } *@
} @* } *@
@* *@
if (errors.Count == 0) { @* if (errors.Count == 0) { *@
await AuditLog.AddUserRolesChangedEvent(user, addedToRoles, removedFromRoles); @* await AuditLog.AddUserRolesChangedEvent(user, addedToRoles, removedFromRoles); *@
await OnEditSuccess(); @* await OnEditSuccess(); *@
} @* } *@
else { @* else { *@
OnEditFailure(string.Join("\n", errors)); @* OnEditFailure(string.Join("\n", errors)); *@
} @* } *@
} @* } *@
@* *@
private sealed class RoleItem { @* private sealed class RoleItem { *@
public RoleInfo Role { get; } @* public RoleInfo Role { get; } *@
public bool Checked { get; set; } @* public bool Checked { get; set; } *@
@* *@
public RoleItem(RoleInfo role, bool @checked) { @* public RoleItem(RoleInfo role, bool @checked) { *@
this.Role = role; @* this.Role = role; *@
this.Checked = @checked; @* this.Checked = @checked; *@
} @* } *@
} @* } *@
@* *@
} @* } *@

View File

@ -28,7 +28,7 @@ static class WebLauncher {
builder.Services.AddSingleton(taskManager); builder.Services.AddSingleton(taskManager);
builder.Services.AddSingleton(serviceConfiguration); builder.Services.AddSingleton(serviceConfiguration);
builder.Services.AddSingleton(controllerConnection); builder.Services.AddSingleton(controllerConnection);
builder.Services.AddPhantomServices(); builder.Services.AddPhantomServices(config.CancellationToken);
builder.Services.AddSingleton<IHostLifetime>(new NullLifetime()); builder.Services.AddSingleton<IHostLifetime>(new NullLifetime());
builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath)); builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath));