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

Compare commits

..

2 Commits

Author SHA1 Message Date
149bb6e0f1
Reimplement Web service 2023-10-31 22:10:09 +01:00
f14d7f5590
WIP 2023-10-31 22:10:09 +01:00
44 changed files with 1657 additions and 1552 deletions

View File

@ -18,8 +18,4 @@ public sealed class ControllerConnection {
public Task Send<TMessage>(TMessage message) where TMessage : IMessageToController {
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

@ -0,0 +1,11 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,17 +9,16 @@ using Serilog;
namespace Phantom.Controller.Services.Users;
public sealed class PermissionManager {
sealed class PermissionManager {
private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>();
private readonly IDbContextProvider dbProvider;
private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new ();
public PermissionManager(IDbContextProvider dbProvider) {
this.dbProvider = dbProvider;
}
internal async Task Initialize() {
public async Task Initialize() {
Logger.Information("Adding default permissions to database.");
await using var ctx = dbProvider.Eager();
@ -37,32 +36,21 @@ public sealed class PermissionManager {
}
}
internal static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
public async Task<PermissionSet> 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 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();
}
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,30 +1,34 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Security.Cryptography;
using Phantom.Common.Data.Web.Users;
namespace Phantom.Controller.Services.Users;
sealed class UserLoginManager {
private const int SessionIdBytes = 20;
private readonly ConcurrentDictionary<string, List<byte[]>> sessionTokensByUsername = new ();
private readonly ConcurrentDictionary<string, List<ImmutableArray<byte>>> sessionTokensByUsername = new ();
private readonly UserManager userManager;
private readonly PermissionManager permissionManager;
public UserLoginManager(UserManager userManager) {
public UserLoginManager(UserManager userManager, PermissionManager permissionManager) {
this.userManager = userManager;
this.permissionManager = permissionManager;
}
public async Task<byte[]?> LogIn(string username, string password) {
public async Task<LogInSuccess?> LogIn(string username, string password) {
var user = await userManager.GetAuthenticated(username, password);
if (user == null) {
return null;
}
var token = RandomNumberGenerator.GetBytes(SessionIdBytes);
var sessionTokens = sessionTokensByUsername.GetOrAdd(username, static _ => new List<byte[]>());
var token = ImmutableArray.Create(RandomNumberGenerator.GetBytes(SessionIdBytes));
var sessionTokens = sessionTokensByUsername.GetOrAdd(username, static _ => new List<ImmutableArray<byte>>());
lock (sessionTokens) {
sessionTokens.Add(token);
}
return token;
return new LogInSuccess(user.UserGuid, await permissionManager.FetchPermissionsForUserId(user.UserGuid), token);
}
}

View File

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

View File

@ -22,7 +22,7 @@ public sealed class RpcConnectionToServer<TListener> {
}
}
public async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class {
public async Task<TReply?> TrySend<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class {
var sequenceId = replyTracker.RegisterReply();
var bytes = messageRegistry.Write<TMessage, TReply>(sequenceId, message).ToArray();
@ -31,6 +31,19 @@ public sealed class RpcConnectionToServer<TListener> {
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);
return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
}

View File

@ -0,0 +1,90 @@
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

@ -1,37 +0,0 @@
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 Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using Phantom.Common.Data.Web.Users;
using Phantom.Common.Logging;
using Phantom.Utils.Cryptography;
using Phantom.Common.Messages.Web.ToController;
using Phantom.Web.Services.Rpc;
using ILogger = Serilog.ILogger;
namespace Phantom.Web.Services.Authentication;
@ -10,66 +10,44 @@ namespace Phantom.Web.Services.Authentication;
public sealed class PhantomLoginManager {
private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginManager>();
public static bool IsAuthenticated(ClaimsPrincipal user) {
return user.Identity is { IsAuthenticated: true };
}
public const string AuthenticationType = "Phantom";
private readonly INavigation navigation;
private readonly PhantomLoginStore loginStore;
private readonly ProtectedSessionStorage sessionStorage;
private readonly PhantomAuthenticationStateProvider authenticationStateProvider;
private readonly ControllerConnection controllerConnection;
public PhantomLoginManager(INavigation navigation, PhantomLoginStore loginStore, ProtectedSessionStorage sessionStorage) {
public PhantomLoginManager(INavigation navigation, PhantomAuthenticationStateProvider authenticationStateProvider, ControllerConnection controllerConnection) {
this.navigation = navigation;
this.loginStore = loginStore;
this.sessionStorage = sessionStorage;
this.authenticationStateProvider = authenticationStateProvider;
this.controllerConnection = controllerConnection;
}
public async Task<bool> SignIn(string username, string password, string? returnUrl = null) {
LogInSuccess? success;
try {
success = await controllerConnection.Send<LogIn, LogInSuccess?>(new LogIn(username, password), TimeSpan.FromSeconds(30));
} catch (Exception e) {
Logger.Error(e, "Could not log in {Username}.", username);
return false;
// if (await userManager.GetAuthenticated(username, password) == null) {
// return false;
// }
}
Logger.Debug("Created login token for {Username}.", username);
if (success == null) {
return false;
}
string token = TokenGenerator.Create(60);
loginStore.Add(token, username, password, returnUrl ?? string.Empty);
navigation.NavigateTo("login" + QueryString.Create("token", token), forceLoad: true);
Logger.Information("Successfully logged in {Username}.", username);
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;
}
internal async Task<SignInResult?> ProcessToken(string token) {
return null;
// 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);
// }
public async Task SignOut() {
await navigation.NavigateTo(string.Empty);
await authenticationStateProvider.HandleLogout();
}
}

View File

@ -0,0 +1,49 @@
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

@ -1,62 +0,0 @@
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

@ -0,0 +1,32 @@
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
public class PermissionManager {
public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) {
return IdentityPermissions.None;
}
public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Web;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Phantom.Web.Services;
namespace Phantom.Web.Base;
@ -27,7 +28,24 @@ sealed class Navigation : INavigation {
return value != null;
}
public void NavigateTo(string url, bool forceLoad = false) {
navigationManager.NavigateTo(BasePath + url, forceLoad);
public async Task NavigateTo(string url, bool forceLoad = false) {
var newPath = BasePath + url;
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" *@
@* @using Phantom.Utils.Collections *@
@* @implements IDisposable *@
@* @inject AgentManager AgentManager *@
@* *@
@* <h1>Agents</h1> *@
@* *@
@* <table class="table align-middle"> *@
@* <thead> *@
@* <tr> *@
@* <Column Width="200px; 44%">Name</Column> *@
@* <Column Width=" 90px; 19%" Class="text-end">Instances</Column> *@
@* <Column Width="145px; 21%" Class="text-end">Memory</Column> *@
@* <Column Width="180px; 8%">Version</Column> *@
@* <Column Width="320px">Identifier</Column> *@
@* <Column Width="100px; 8%" Class="text-center">Status</Column> *@
@* <Column Width="215px" Class="text-end">Last Ping</Column> *@
@* </tr> *@
@* </thead> *@
@* @if (!agentTable.IsEmpty) { *@
@* <tbody> *@
@* @foreach (var agent in agentTable) { *@
@* var usedInstances = agent.Stats?.RunningInstanceCount; *@
@* var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes; *@
@* *@
@* <tr> *@
@* <td>@agent.Name</td> *@
@* <td class="text-end"> *@
@* <ProgressBar Value="@(usedInstances ?? 0)" Maximum="@agent.MaxInstances"> *@
@* @(usedInstances?.ToString() ?? "?") / @agent.MaxInstances *@
@* </ProgressBar> *@
@* </td> *@
@* <td class="text-end"> *@
@* <ProgressBar Value="@(usedMemory ?? 0)" Maximum="@agent.MaxMemory.InMegabytes"> *@
@* @(usedMemory?.ToString() ?? "?") / @agent.MaxMemory.InMegabytes MB *@
@* </ProgressBar> *@
@* </td> *@
@* <td class="text-condensed"> *@
@* Build: <code>@agent.BuildVersion</code> *@
@* <br> *@
@* Protocol: <code>v@(agent.ProtocolVersion)</code> *@
@* </td> *@
@* <td> *@
@* <code class="text-uppercase">@agent.Guid.ToString()</code> *@
@* </td> *@
@* @if (agent.IsOnline) { *@
@* <td class="text-center text-success">Online</td> *@
@* <td class="text-end"></td> *@
@* } *@
@* else { *@
@* <td class="text-center text-danger">Offline</td> *@
@* @if (agent.LastPing is {} lastPing) { *@
@* <td class="text-end"> *@
@* <time datetime="@lastPing.ToString("o")" data-time-type="relative">@lastPing.ToString()</time> *@
@* </td> *@
@* } *@
@* else { *@
@* <td class="text-end">-</td> *@
@* } *@
@* } *@
@* </tr> *@
@* } *@
@* </tbody> *@
@* } *@
@* else { *@
@* <tfoot> *@
@* <tr> *@
@* <td colspan="7">No agents registered.</td> *@
@* </tr> *@
@* </tfoot> *@
@* } *@
@* </table> *@
@* *@
@* @code { *@
@* *@
@* private readonly Table<Agent, Guid> agentTable = new(); *@
@* *@
@* protected override void OnInitialized() { *@
@* AgentManager.AgentsChanged.Subscribe(this, agents => { *@
@* 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); *@
@* InvokeAsync(StateHasChanged); *@
@* }); *@
@* } *@
@* *@
@* void IDisposable.Dispose() { *@
@* AgentManager.AgentsChanged.Unsubscribe(this); *@
@* } *@
@* *@
@* } *@
@page "/agents"
@using Phantom.Utils.Collections
@implements IDisposable
@inject AgentManager AgentManager
<h1>Agents</h1>
<table class="table align-middle">
<thead>
<tr>
<Column Width="200px; 44%">Name</Column>
<Column Width=" 90px; 19%" Class="text-end">Instances</Column>
<Column Width="145px; 21%" Class="text-end">Memory</Column>
<Column Width="180px; 8%">Version</Column>
<Column Width="320px">Identifier</Column>
<Column Width="100px; 8%" Class="text-center">Status</Column>
<Column Width="215px" Class="text-end">Last Ping</Column>
</tr>
</thead>
@if (!agentTable.IsEmpty) {
<tbody>
@foreach (var agent in agentTable) {
var usedInstances = agent.Stats?.RunningInstanceCount;
var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes;
<tr>
<td>@agent.Name</td>
<td class="text-end">
<ProgressBar Value="@(usedInstances ?? 0)" Maximum="@agent.MaxInstances">
@(usedInstances?.ToString() ?? "?") / @agent.MaxInstances
</ProgressBar>
</td>
<td class="text-end">
<ProgressBar Value="@(usedMemory ?? 0)" Maximum="@agent.MaxMemory.InMegabytes">
@(usedMemory?.ToString() ?? "?") / @agent.MaxMemory.InMegabytes MB
</ProgressBar>
</td>
<td class="text-condensed">
Build: <code>@agent.BuildVersion</code>
<br>
Protocol: <code>v@(agent.ProtocolVersion)</code>
</td>
<td>
<code class="text-uppercase">@agent.Guid.ToString()</code>
</td>
@if (agent.IsOnline) {
<td class="text-center text-success">Online</td>
<td class="text-end"></td>
}
else {
<td class="text-center text-danger">Offline</td>
@if (agent.LastPing is {} lastPing) {
<td class="text-end">
<time datetime="@lastPing.ToString("o")" data-time-type="relative">@lastPing.ToString()</time>
</td>
}
else {
<td class="text-end">-</td>
}
}
</tr>
}
</tbody>
}
else {
<tfoot>
<tr>
<td colspan="7">No agents registered.</td>
</tr>
</tfoot>
}
</table>
@code {
private readonly Table<Agent, Guid> agentTable = new();
protected override void OnInitialized() {
AgentManager.AgentsChanged.Subscribe(this, agents => {
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);
InvokeAsync(StateHasChanged);
});
}
void IDisposable.Dispose() {
AgentManager.AgentsChanged.Unsubscribe(this);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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