diff --git a/AccessQueuePlayground/AccessQueuePlayground.csproj b/AccessQueuePlayground/AccessQueuePlayground.csproj
new file mode 100644
index 0000000..4a7df85
--- /dev/null
+++ b/AccessQueuePlayground/AccessQueuePlayground.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AccessQueuePlayground/Components/App.razor b/AccessQueuePlayground/Components/App.razor
new file mode 100644
index 0000000..58fede0
--- /dev/null
+++ b/AccessQueuePlayground/Components/App.razor
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AccessQueuePlayground/Components/Layout/MainLayout.razor b/AccessQueuePlayground/Components/Layout/MainLayout.razor
new file mode 100644
index 0000000..0fd1b20
--- /dev/null
+++ b/AccessQueuePlayground/Components/Layout/MainLayout.razor
@@ -0,0 +1,9 @@
+@inherits LayoutComponentBase
+
+@Body
+
+
+ An unhandled error has occurred.
+
Reload
+
🗙
+
diff --git a/AccessQueuePlayground/Components/Layout/MainLayout.razor.css b/AccessQueuePlayground/Components/Layout/MainLayout.razor.css
new file mode 100644
index 0000000..df8c10f
--- /dev/null
+++ b/AccessQueuePlayground/Components/Layout/MainLayout.razor.css
@@ -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;
+ }
diff --git a/AccessQueuePlayground/Components/Pages/Error.razor b/AccessQueuePlayground/Components/Pages/Error.razor
new file mode 100644
index 0000000..576cc2d
--- /dev/null
+++ b/AccessQueuePlayground/Components/Pages/Error.razor
@@ -0,0 +1,36 @@
+@page "/Error"
+@using System.Diagnostics
+
+Error
+
+Error.
+An error occurred while processing your request.
+
+@if (ShowRequestId)
+{
+
+ Request ID: @RequestId
+
+}
+
+Development Mode
+
+ Swapping to Development environment will display more detailed information about the error that occurred.
+
+
+ The Development environment shouldn't be enabled for deployed applications.
+ It can result in displaying sensitive information from exceptions to end users.
+ For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development
+ and restarting the app.
+
+
+@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;
+}
diff --git a/AccessQueuePlayground/Components/Pages/Home.razor b/AccessQueuePlayground/Components/Pages/Home.razor
new file mode 100644
index 0000000..33cd1d9
--- /dev/null
+++ b/AccessQueuePlayground/Components/Pages/Home.razor
@@ -0,0 +1,95 @@
+@page "/"
+@using AccessQueuePlayground.Models
+@using AccessQueuePlayground.Services
+@using BlazorBootstrap
+
+@inject IAccessQueueManager Manager
+
+AccessQueue Playground
+
+Add User
+Refresh
+
+@if (Status != null)
+{
+ Users with access
+
+
+
+ @context.Id
+
+
+ @context.LatestResponse?.ExpiresOn
+
+
+
+
+
+
+
+
+ Users in queue
+
+
+
+ @context.Id
+
+
+ @(context.LatestResponse?.RequestsAhead ?? 0 + 1)
+
+
+
+
+
+
+
+
+ Inactive users
+
+
+
+ @context.Id
+
+
+
+
+
+
+
+
+}
+
+@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);
+ }
+}
\ No newline at end of file
diff --git a/AccessQueuePlayground/Components/Routes.razor b/AccessQueuePlayground/Components/Routes.razor
new file mode 100644
index 0000000..f756e19
--- /dev/null
+++ b/AccessQueuePlayground/Components/Routes.razor
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/AccessQueuePlayground/Components/_Imports.razor b/AccessQueuePlayground/Components/_Imports.razor
new file mode 100644
index 0000000..4559e04
--- /dev/null
+++ b/AccessQueuePlayground/Components/_Imports.razor
@@ -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
diff --git a/AccessQueuePlayground/Models/AccessQueueStatus.cs b/AccessQueuePlayground/Models/AccessQueueStatus.cs
new file mode 100644
index 0000000..c773658
--- /dev/null
+++ b/AccessQueuePlayground/Models/AccessQueueStatus.cs
@@ -0,0 +1,11 @@
+using AccessQueueService.Models;
+
+namespace AccessQueuePlayground.Models
+{
+ public class AccessQueueStatus
+ {
+ public List AccessUsers { get; set; } = [];
+ public List QueuedUsers { get; set; } = [];
+ public List InactiveUsers { get; set; } = [];
+ }
+}
diff --git a/AccessQueuePlayground/Models/User.cs b/AccessQueuePlayground/Models/User.cs
new file mode 100644
index 0000000..e005ed9
--- /dev/null
+++ b/AccessQueuePlayground/Models/User.cs
@@ -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; }
+ }
+}
diff --git a/AccessQueuePlayground/Program.cs b/AccessQueuePlayground/Program.cs
new file mode 100644
index 0000000..9ad2124
--- /dev/null
+++ b/AccessQueuePlayground/Program.cs
@@ -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();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+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()
+ .AddInteractiveServerRenderMode();
+
+app.Run();
diff --git a/AccessQueuePlayground/Properties/launchSettings.json b/AccessQueuePlayground/Properties/launchSettings.json
new file mode 100644
index 0000000..f310682
--- /dev/null
+++ b/AccessQueuePlayground/Properties/launchSettings.json
@@ -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"
+ }
+ }
+ }
+ }
diff --git a/AccessQueuePlayground/Services/AccessQueueBackgroundService.cs b/AccessQueuePlayground/Services/AccessQueueBackgroundService.cs
new file mode 100644
index 0000000..77765ce
--- /dev/null
+++ b/AccessQueuePlayground/Services/AccessQueueBackgroundService.cs
@@ -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
+ }
+ }
+ }
+}
diff --git a/AccessQueuePlayground/Services/AccessQueueManager.cs b/AccessQueuePlayground/Services/AccessQueueManager.cs
new file mode 100644
index 0000000..69846b3
--- /dev/null
+++ b/AccessQueuePlayground/Services/AccessQueueManager.cs
@@ -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 _users;
+ private AccessQueueStatus _status;
+ public event Action? StatusUpdated;
+
+ private void NotifyStatusUpdated()
+ {
+ StatusUpdated?.Invoke();
+ }
+
+ public AccessQueueManager(IAccessService accessService)
+ {
+ _accessService = accessService;
+ _users = new ConcurrentDictionary();
+ _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();
+ }
+ }
+}
diff --git a/AccessQueuePlayground/Services/IAccessQueueManager.cs b/AccessQueuePlayground/Services/IAccessQueueManager.cs
new file mode 100644
index 0000000..29f6b3f
--- /dev/null
+++ b/AccessQueuePlayground/Services/IAccessQueueManager.cs
@@ -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);
+
+ }
+}
diff --git a/AccessQueuePlayground/appsettings.Development.json b/AccessQueuePlayground/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/AccessQueuePlayground/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/AccessQueuePlayground/appsettings.json b/AccessQueuePlayground/appsettings.json
new file mode 100644
index 0000000..fcc9c4b
--- /dev/null
+++ b/AccessQueuePlayground/appsettings.json
@@ -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": "*"
+}
diff --git a/AccessQueuePlayground/wwwroot/app.css b/AccessQueuePlayground/wwwroot/app.css
new file mode 100644
index 0000000..e398853
--- /dev/null
+++ b/AccessQueuePlayground/wwwroot/app.css
@@ -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;
+}
diff --git a/AccessQueueService.sln b/AccessQueueService.sln
index a3ecab9..82ba322 100644
--- a/AccessQueueService.sln
+++ b/AccessQueueService.sln
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueService", "Acces
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueServiceTests", "AccessQueueServiceTests\AccessQueueServiceTests.csproj", "{1DF48A19-A2B3-4B0C-B726-E65B8E023760}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueuePlayground", "AccessQueuePlayground\AccessQueuePlayground.csproj", "{65D5E841-7B02-4A55-89C6-12082FA1BCAF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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}.Release|Any CPU.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE