Merge pull request 'Adding playground blazor app for manually experimenting with access queue' (#5) from add-playground into main

Reviewed-on: https://git.hobbs.zone/henry/AccessQueueService/pulls/5
This commit is contained in:
henry 2025-05-12 23:48:18 +00:00
commit a41daddb11
19 changed files with 488 additions and 0 deletions

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Blazor.Bootstrap" Version="3.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AccessQueueService\AccessQueueService.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="AccessQueuePlayground.styles.css" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link href="_content/Blazor.Bootstrap/blazor.bootstrap.css" rel="stylesheet" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<script src="_content/Blazor.Bootstrap/blazor.bootstrap.js"></script>
</body>
</html>

View File

@ -0,0 +1,9 @@
@inherits LayoutComponentBase
@Body
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>

View File

@ -0,0 +1,18 @@
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@ -0,0 +1,95 @@
@page "/"
@using AccessQueuePlayground.Models
@using AccessQueuePlayground.Services
@using BlazorBootstrap
@inject IAccessQueueManager Manager
<PageTitle>AccessQueue Playground</PageTitle>
<Button Color="ButtonColor.Success" @onclick="AddUser">Add User</Button>
<Button Color="ButtonColor.Primary" @onclick="Refresh">Refresh</Button>
@if (Status != null)
{
<h2>Users with access</h2>
<Grid TItem="User" Data="Status.AccessUsers" Class="table table-bordered mt-3" AllowSorting>
<GridColumns>
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
@context.Id
</GridColumn>
<GridColumn TItem="User" HeaderText="Expiration" PropertyName="LatestResponse?.ExpiresOn" SortKeySelector="item => item.LatestResponse.ExpiresOn">
@context.LatestResponse?.ExpiresOn
</GridColumn>
<GridColumn TItem="User" HeaderText="Active">
<ChildContent>
<Switch Value="context.Active" ValueExpression="() => context.Active" ValueChanged="(_) => ToggleUserActive(context.Id)" />
</ChildContent>
</GridColumn>
</GridColumns>
</Grid>
<h2>Users in queue</h2>
<Grid TItem="User" Data="Status.QueuedUsers" Class="table table-bordered mt-3" AllowSorting>
<GridColumns>
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
@context.Id
</GridColumn>
<GridColumn TItem="User" HeaderText="Queue Postition" PropertyName="LatestResponse?.RequestsAhead" SortKeySelector="item => item.LatestResponse.RequestsAhead">
@(context.LatestResponse?.RequestsAhead ?? 0 + 1)
</GridColumn>
<GridColumn TItem="User" HeaderText="Active">
<ChildContent>
<Switch Value="context.Active" ValueExpression="() => context.Active" ValueChanged="(_) => ToggleUserActive(context.Id)" />
</ChildContent>
</GridColumn>
</GridColumns>
</Grid>
<h2>Inactive users</h2>
<Grid TItem="User" Data="Status.InactiveUsers" Class="table table-bordered mt-3" AllowSorting>
<GridColumns>
<GridColumn TItem="User" HeaderText="Id" PropertyName="Id" SortKeySelector="item => item.Id">
@context.Id
</GridColumn>
<GridColumn TItem="User" HeaderText="Active">
<ChildContent>
<Switch Value="context.Active" ValueExpression="() => context.Active" ValueChanged="(_) => ToggleUserActive(context.Id)" />
</ChildContent>
</GridColumn>
</GridColumns>
</Grid>
}
@code {
public AccessQueueStatus? Status;
protected override void OnInitialized()
{
Manager.StatusUpdated += OnStatusUpdated;
Status = Manager.GetStatus();
}
private void OnStatusUpdated()
{
InvokeAsync(() =>
{
Status = Manager.GetStatus();
StateHasChanged();
});
}
public void Refresh()
{
Status = Manager.GetStatus();
}
public void AddUser()
{
Manager.AddUser();
Status = Manager.GetStatus();
}
public void ToggleUserActive(Guid userId)
{
Manager.ToggleUserActivity(userId);
}
}

View File

@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View File

@ -0,0 +1,10 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using AccessQueuePlayground
@using AccessQueuePlayground.Components

View File

@ -0,0 +1,11 @@
using AccessQueueService.Models;
namespace AccessQueuePlayground.Models
{
public class AccessQueueStatus
{
public List<User> AccessUsers { get; set; } = [];
public List<User> QueuedUsers { get; set; } = [];
public List<User> InactiveUsers { get; set; } = [];
}
}

View File

@ -0,0 +1,11 @@
using AccessQueueService.Models;
namespace AccessQueuePlayground.Models
{
public class User
{
public Guid Id { get; set; }
public bool Active { get; set; }
public AccessResponse? LatestResponse { get; set; }
}
}

View File

@ -0,0 +1,34 @@
using AccessQueuePlayground.Components;
using AccessQueuePlayground.Services;
using AccessQueueService.Data;
using AccessQueueService.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddSingleton<IAccessService, AccessService>();
builder.Services.AddSingleton<IAccessQueueRepo, TakeANumberAccessQueueRepo>();
builder.Services.AddSingleton<IAccessQueueManager, AccessQueueManager>();
builder.Services.AddHostedService<AccessQueueBackgroundService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();

View File

@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:25310",
"sslPort": 44353
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5108",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7211;http://localhost:5108",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
namespace AccessQueuePlayground.Services
{
public class AccessQueueBackgroundService : BackgroundService
{
private readonly IAccessQueueManager _accessQueueManager;
public AccessQueueBackgroundService(IAccessQueueManager accessQueueManager)
{
_accessQueueManager = accessQueueManager;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await _accessQueueManager.RecalculateStatus();
await Task.Delay(1000, stoppingToken); // Run every second
}
}
}
}

View File

@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using AccessQueuePlayground.Models;
using AccessQueueService.Models;
using AccessQueueService.Services;
namespace AccessQueuePlayground.Services
{
public class AccessQueueManager : IAccessQueueManager
{
private readonly IAccessService _accessService;
private readonly ConcurrentDictionary<Guid, User> _users;
private AccessQueueStatus _status;
public event Action? StatusUpdated;
private void NotifyStatusUpdated()
{
StatusUpdated?.Invoke();
}
public AccessQueueManager(IAccessService accessService)
{
_accessService = accessService;
_users = new ConcurrentDictionary<Guid, User>();
_status = new AccessQueueStatus();
}
public AccessQueueStatus GetStatus() => _status;
public Guid AddUser()
{
var id = Guid.NewGuid();
_users[id] = new User
{
Id = id,
Active = true,
};
return id;
}
public void ToggleUserActivity(Guid userId)
{
var user = _users[userId];
if (user != null)
{
user.Active = !user.Active;
}
}
public async Task RecalculateStatus()
{
var userList = _users.Values.ToList();
var newStatus = new AccessQueueStatus();
foreach (var user in userList)
{
if (user.Active)
{
user.LatestResponse = await _accessService.RequestAccess(user.Id);
if (user.LatestResponse?.HasAccess ?? false)
{
newStatus.AccessUsers.Add(user);
}
else
{
newStatus.QueuedUsers.Add(user);
}
}
else
{
if(user.LatestResponse?.ExpiresOn != null && user.LatestResponse.ExpiresOn > DateTime.UtcNow)
{
newStatus.AccessUsers.Add(user);
}
else
{
newStatus.InactiveUsers.Add(user);
}
}
}
_status = newStatus;
NotifyStatusUpdated();
}
}
}

View File

@ -0,0 +1,14 @@
using AccessQueuePlayground.Models;
namespace AccessQueuePlayground.Services
{
public interface IAccessQueueManager
{
public event Action? StatusUpdated;
public Task RecalculateStatus();
public AccessQueueStatus GetStatus();
public Guid AddUser();
public void ToggleUserActivity(Guid userId);
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AccessQueue": {
"CapacityLimit": 3, // Maximum number of active users
"ActivitySeconds": 2, // Time since last access before a user is considered inactive
"ExpirationSeconds": 10, // 12 hours - Time before a user access is revoked
"RollingExpiration": true // Whether to extend expiration time on access
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,29 @@
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}

View File

@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueService", "Acces
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueServiceTests", "AccessQueueServiceTests\AccessQueueServiceTests.csproj", "{1DF48A19-A2B3-4B0C-B726-E65B8E023760}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueServiceTests", "AccessQueueServiceTests\AccessQueueServiceTests.csproj", "{1DF48A19-A2B3-4B0C-B726-E65B8E023760}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueuePlayground", "AccessQueuePlayground\AccessQueuePlayground.csproj", "{65D5E841-7B02-4A55-89C6-12082FA1BCAF}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -21,6 +23,10 @@ Global
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Debug|Any CPU.Build.0 = Debug|Any CPU {1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.ActiveCfg = Release|Any CPU {1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.Build.0 = Release|Any CPU {1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.Build.0 = Release|Any CPU
{65D5E841-7B02-4A55-89C6-12082FA1BCAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{65D5E841-7B02-4A55-89C6-12082FA1BCAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{65D5E841-7B02-4A55-89C6-12082FA1BCAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{65D5E841-7B02-4A55-89C6-12082FA1BCAF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE