diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/JournaledTodoList.AppHost.csproj b/orleans/JournaledTodoList/JournaledTodoList.AppHost/JournaledTodoList.AppHost.csproj new file mode 100644 index 00000000000..39de8f582e4 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/JournaledTodoList.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + + Exe + net9.0 + enable + enable + true + 1df8a68d-9c06-4d9a-98ed-f735876b11f7 + + + + + + + + + + + + + diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/Program.cs b/orleans/JournaledTodoList/JournaledTodoList.AppHost/Program.cs new file mode 100644 index 00000000000..5f3e032d932 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/Program.cs @@ -0,0 +1,27 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// Add the resources which you will use for Orleans clustering and +// grain state storage. +var sessionStorage = builder.AddAzureStorage("sessionStorage") + .RunAsEmulator(); +var persistentStorage = builder.AddAzureStorage("persistentStorage") + .RunAsEmulator(config => config.WithLifetime(ContainerLifetime.Persistent)); + +var clusteringTable = sessionStorage.AddTables("clustering"); +var grainStorage = persistentStorage.AddBlobs("grain-state"); + +// Add the Orleans resource to the Aspire DistributedApplication +// builder, then configure it with Azure Table Storage for clustering +// and Azure Blob Storage for grain storage. +var orleans = builder.AddOrleans("default") + .WithClustering(clusteringTable) + .WithGrainStorage("Default", grainStorage); + +builder.AddProject("webapp") + .WithReference(orleans) + .WithReplicas(3) + .WithExternalHttpEndpoints() + .WaitFor(clusteringTable) + .WaitFor(grainStorage); + +builder.Build().Run(); diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/Properties/launchSettings.json b/orleans/JournaledTodoList/JournaledTodoList.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..71382477d85 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17222;http://localhost:15135", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21055", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22157" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15135", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19238", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20164" + } + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.Development.json b/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.json b/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/Extensions.cs b/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000000..14aa84b9b89 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/Extensions.cs @@ -0,0 +1,122 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter("Microsoft.Orleans"); + }) + .WithTracing(tracing => + { + tracing.AddSource("Microsoft.Orleans.Runtime"); + tracing.AddSource("Microsoft.Orleans.Application"); + + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/JournaledTodoList.ServiceDefaults.csproj b/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/JournaledTodoList.ServiceDefaults.csproj new file mode 100644 index 00000000000..24b1b4fee9e --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/JournaledTodoList.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/App.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/App.razor new file mode 100644 index 00000000000..b46dac45008 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/App.razor @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..dada90be88b --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor @@ -0,0 +1,24 @@ +@inherits LayoutComponentBase + +
+
+ +
+ +
+ @Body +
+
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor.css b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..60cec92d5e5 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + 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/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor new file mode 100644 index 00000000000..14e4d93f3e9 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor @@ -0,0 +1,17 @@ + diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs new file mode 100644 index 00000000000..5fd6c2ce8ae --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs @@ -0,0 +1,28 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains; +using JournaledTodoList.WebApp.Services; + +namespace JournaledTodoList.WebApp.Components.Layout; + +public partial class NavBar(TodoListService todoListService) : ITodoListRegistryObserver, IDisposable +{ + private IDisposable? subscription; + + private ImmutableArray TodoLists { get; set; } = []; + + protected override async Task OnInitializedAsync() + { + subscription = await todoListService.SubscribeAsync(this); + } + + public void Dispose() + { + subscription?.Dispose(); + } + + async Task ITodoListRegistryObserver.OnTodoListsChanged(ImmutableArray todoLists) => await InvokeAsync(() => + { + TodoLists = todoLists; + StateHasChanged(); + }); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/Error.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/Error.razor new file mode 100644 index 00000000000..576cc2d2f4d --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/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/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor new file mode 100644 index 00000000000..05ba5a10410 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor @@ -0,0 +1,24 @@ +@page "/" +Todo Lists + +
+

My Todo Lists

+ +
+
+
+ + +
+
+
+ +
+ @foreach (var list in TodoLists) + { + + @list.Name + + } +
+
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs new file mode 100644 index 00000000000..f8eb7cb2027 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs @@ -0,0 +1,40 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains; +using JournaledTodoList.WebApp.Services; + +namespace JournaledTodoList.WebApp.Components.Pages; + +public partial class HomePage(TodoListService todoListService) : ITodoListRegistryObserver, IDisposable +{ + private IDisposable? subscription; + private string newListName = ""; + + private ImmutableArray TodoLists { get; set; } = []; + + protected override async Task OnInitializedAsync() + { + subscription = await todoListService.SubscribeAsync(this); + } + + public void Dispose() + { + subscription?.Dispose(); + } + + async Task ITodoListRegistryObserver.OnTodoListsChanged(ImmutableArray todoLists) => await InvokeAsync(() => + { + TodoLists = todoLists; + StateHasChanged(); + }); + + private async Task CreateNewList(string listName) + { + if (string.IsNullOrWhiteSpace(listName) || TodoLists.Any(x => x.Name == listName)) + { + return; + } + + await todoListService.CreateTodoListAsync(listName); + newListName = ""; + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor new file mode 100644 index 00000000000..11657deffd0 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor @@ -0,0 +1,102 @@ +@page "/todolist/{ListId}" +Todo List - @ListId +
+
+
+

Todo List: @ListId

+
+
+

Event History

+
+
+ +
+
+ @if (IsViewingHistory) + { + + } + else + { +
+
+ + +
+
+ } +
+ +
+ +
+
+ +
+
+ @if (todoList is null) + { +
Loading...
+ } + else if (todoList.Items.IsEmpty) + { +
No items in this list yet. Add one above!
+ } + else + { +
+ @foreach (var item in todoList.Items) + { +
+
+ +
+ + +
+ } +
+ } +
+
+ @if (history.IsDefault) + { +
Loading history...
+ } + else if (history.IsEmpty) + { +
No history yet.
+ } + else + { +
+ @foreach (var evt in history.OrderByDescending(e => e.Timestamp)) + { + + } +
+ } +
+
+
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs new file mode 100644 index 00000000000..81ffdae56cf --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs @@ -0,0 +1,107 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using JournaledTodoList.WebApp.Grains; +using JournaledTodoList.WebApp.Grains.Events; +using JournaledTodoList.WebApp.Services; +using Microsoft.AspNetCore.Components; + +namespace JournaledTodoList.WebApp.Components.Pages; + +public partial class TodoListPage(TodoListService todoService) +{ + private string newItemTitle = ""; + private TodoList? todoList; + private ImmutableArray history; + private DateTimeOffset? currentViewTimestamp; + + [MemberNotNullWhen(true, nameof(currentViewTimestamp))] + private bool IsViewingHistory => !history.IsDefaultOrEmpty && currentViewTimestamp < history[^1].Timestamp; + + [Parameter, EditorRequired] + public required string ListId { get; set; } + + protected override async Task OnParametersSetAsync() + { + if (todoList?.Name != ListId) + { + currentViewTimestamp = null; + todoList = null; + await LoadTodoList(); + } + } + + private async Task LoadTodoList() + { + todoList = await todoService.GetTodoListAsync(ListId); + history = await todoService.GetTodoListHistoryAsync(ListId); + } + + private async Task AddItem() + { + if (string.IsNullOrWhiteSpace(newItemTitle) || IsViewingHistory) + { + return; + } + + await todoService.AddTodoItemAsync(ListId, newItemTitle); + newItemTitle = ""; + await LoadTodoList(); + } + + private async Task UpdateItem(int itemId, string? title) + { + if (string.IsNullOrWhiteSpace(title) || IsViewingHistory) + { + return; + } + + await todoService.UpdateTodoItemAsync(ListId, itemId, title); + await LoadTodoList(); + } + + private async Task ToggleItem(int itemId) + { + if (IsViewingHistory) + { + return; + } + + await todoService.ToggleTodoItemAsync(ListId, itemId); + await LoadTodoList(); + } + + private async Task RemoveItem(int itemId) + { + if (IsViewingHistory) + { + return; + } + + await todoService.RemoveTodoItemAsync(ListId, itemId); + await LoadTodoList(); + } + + private async Task ViewAtTimestamp(DateTimeOffset timestamp) + { + if (timestamp == history[^1].Timestamp) + { + currentViewTimestamp = null; + } + else + { + currentViewTimestamp = timestamp; + todoList = await todoService.GetTodoListAtTimestampAsync(ListId, timestamp); + } + } + + private async Task ReturnToCurrentVersion() + { + currentViewTimestamp = null; + await LoadTodoList(); + } + + private bool IsCurrentHistoryItem(TodoListEvent item) + => currentViewTimestamp.HasValue + ? item.Timestamp == currentViewTimestamp + : history[^1] == item; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Routes.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Routes.razor new file mode 100644 index 00000000000..6d6b5d6b258 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Routes.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/_Imports.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/_Imports.razor new file mode 100644 index 00000000000..1fa622ca8b7 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/_Imports.razor @@ -0,0 +1,12 @@ +@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 JournaledTodoList.WebApp +@using JournaledTodoList.WebApp.Components +@using JournaledTodoList.WebApp.Grains +@using JournaledTodoList.WebApp.Services diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs new file mode 100644 index 00000000000..fc242367cb9 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs @@ -0,0 +1,6 @@ +public static class Constants +{ + public const string StateStorageProviderName = "StateStorage"; + + public const string TodoListRegistryId = "registry"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs new file mode 100644 index 00000000000..62a70b369d4 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs @@ -0,0 +1,8 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoItemAdded(int ItemId, DateTimeOffset Timestamp, string Title) + : TodoListEvent(Timestamp) +{ + public override string GetDescription() => $"Added item {ItemId}: {Title}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs new file mode 100644 index 00000000000..04f1559812c --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoItemRemoved(int ItemId, DateTimeOffset Timestamp) : TodoListEvent(Timestamp) +{ + public override string GetDescription() => $"Removed item {ItemId}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs new file mode 100644 index 00000000000..b7a915048ee --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoItemToggled(int ItemId, DateTimeOffset Timestamp) : TodoListEvent(Timestamp) +{ + public override string GetDescription() => $"Toggled completion status of item {ItemId}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs new file mode 100644 index 00000000000..da235161aa7 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoItemUpdated(int ItemId, DateTimeOffset Timestamp, string Title) : TodoListEvent(Timestamp) +{ + public override string GetDescription() => $"Updated item {ItemId}: {Title}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs new file mode 100644 index 00000000000..3407ac361ee --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public abstract record class TodoListEvent(DateTimeOffset Timestamp) +{ + public abstract string GetDescription(); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListNameChanged.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListNameChanged.cs new file mode 100644 index 00000000000..fe90e337917 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListNameChanged.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoListNameChanged(string Name, DateTimeOffset Timestamp) : TodoListEvent(Timestamp) +{ + public override string GetDescription() => $"Todo list name changed to {Name}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs new file mode 100644 index 00000000000..5be8886df9a --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs @@ -0,0 +1,23 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains.Events; + +namespace JournaledTodoList.WebApp.Grains; + +public interface ITodoListGrain : IGrainWithStringKey +{ + Task GetTodoListAsync(); + + Task GetTodoListAtTimestampAsync(DateTimeOffset timestamp); + + Task AddTodoItemAsync(string title); + + Task UpdateTodoItemAsync(int id, string title); + + Task ToggleTodoItemAsync(int id); + + Task RemoveTodoItemAsync(int id); + + Task SetNameAsync(string listName); + + Task> GetHistoryAsync(); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs new file mode 100644 index 00000000000..5b285e0d820 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs @@ -0,0 +1,14 @@ +using System.Collections.Immutable; + +namespace JournaledTodoList.WebApp.Grains; + +public interface ITodoListRegistryGrain : IGrainWithStringKey +{ + Task RegisterTodoListAsync(TodoListReference todoListReference); + + Task> GetAllTodoListsAsync(); + + Task Subscribe(ITodoListRegistryObserver observer); + + Task Unsubscribe(ITodoListRegistryObserver observer); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs new file mode 100644 index 00000000000..cda0f551338 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs @@ -0,0 +1,8 @@ +using System.Collections.Immutable; + +namespace JournaledTodoList.WebApp.Grains; + +public interface ITodoListRegistryObserver : IGrainObserver +{ + Task OnTodoListsChanged(ImmutableArray todoLists); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoItem.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoItem.cs new file mode 100644 index 00000000000..7123804804a --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoItem.cs @@ -0,0 +1,4 @@ +namespace JournaledTodoList.WebApp.Grains; + +[GenerateSerializer, Immutable] +public record class TodoItem(int Id, string Title, bool IsCompleted); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs new file mode 100644 index 00000000000..536fd119205 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs @@ -0,0 +1,9 @@ +using System.Collections.Immutable; + +namespace JournaledTodoList.WebApp.Grains; + +[GenerateSerializer, Immutable] +public record class TodoList( + string Name, + ImmutableArray Items, + DateTimeOffset Timestamp); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs new file mode 100644 index 00000000000..922e6577291 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs @@ -0,0 +1,139 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains.Events; +using Orleans.EventSourcing; + +namespace JournaledTodoList.WebApp.Grains; + +public sealed class TodoListGrain : JournaledGrain, ITodoListGrain +{ + public async Task> GetHistoryAsync() + { + var events = await RetrieveConfirmedEvents(0, Version); + return events.ToImmutableArray(); + } + + public async Task GetTodoListAtTimestampAsync(DateTimeOffset timestamp) + { + // Get all events up to the current version + var allEvents = await GetHistoryAsync(); + + // Create a fresh projection and apply the filtered events + var historicalProjection = new TodoListProjection(); + foreach (var evt in allEvents.Where(e => e.Timestamp <= timestamp)) + { + TransitionState(historicalProjection, evt); + } + + // Only return a TodoList if there were events that matched the timestamp. + return historicalProjection.Timestamp > DateTimeOffset.MinValue + ? new TodoList( + Name: this.GetPrimaryKeyString(), + Items: historicalProjection.Items.Values.OrderBy(x => x.Id).ToImmutableArray(), + Timestamp: historicalProjection.Timestamp) + : null; + } + + public async Task GetTodoListAsync() + { + var list = new TodoList( + Name: this.GetPrimaryKeyString(), + Items: State.Items.Values.OrderBy(x => x.Id).ToImmutableArray(), + Timestamp: State.Timestamp); + + return list; + } + + public async Task AddTodoItemAsync(string title) + { + var evt = new TodoItemAdded( + Version, + DateTimeOffset.UtcNow, + title); + + RaiseEvent(evt); + await ConfirmEvents(); + } + + public async Task UpdateTodoItemAsync(int id, string title) + { + var evt = new TodoItemUpdated(id, DateTimeOffset.UtcNow, title); + RaiseEvent(evt); + await ConfirmEvents(); + } + + public async Task ToggleTodoItemAsync(int id) + { + var evt = new TodoItemToggled(id, DateTimeOffset.UtcNow); + RaiseEvent(evt); + await ConfirmEvents(); + } + + public async Task RemoveTodoItemAsync(int id) + { + var evt = new TodoItemRemoved(id, DateTimeOffset.UtcNow); + RaiseEvent(evt); + await ConfirmEvents(); + } + + public async Task SetNameAsync(string listName) + { + var evt = new TodoListNameChanged(listName, DateTimeOffset.UtcNow); + RaiseEvent(evt); + await ConfirmEvents(); + + // Publish list with new name to todo list registry. + // The registry only cares about the id and name of the list, + // so this is the only place where we need to interact with the registry. + var registry = GrainFactory.GetGrain(Constants.TodoListRegistryId); + await registry.RegisterTodoListAsync(new TodoListReference(this.GetPrimaryKeyString(), listName)); + } + + /// + /// The state container for . + /// NOTE: this has to be a mutable object. + /// + public sealed class TodoListProjection + { + public Dictionary Items { get; set; } = []; + + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.MinValue; + + public string Name { get; set; } = string.Empty; + + public void Apply(TodoItemAdded added) + { + Items.Add(added.ItemId, new TodoItem(added.ItemId, added.Title, false)); + Timestamp = added.Timestamp; + } + + public void Apply(TodoItemUpdated updated) + { + if (Items.TryGetValue(updated.ItemId, out var item)) + { + Items[updated.ItemId] = item with { Title = updated.Title }; + } + Timestamp = updated.Timestamp; + } + + public void Apply(TodoItemToggled toggled) + { + if (Items.TryGetValue(toggled.ItemId, out var item)) + { + Items[toggled.ItemId] = item with { IsCompleted = !item.IsCompleted }; + } + Timestamp = toggled.Timestamp; + } + + public void Apply(TodoItemRemoved removed) + { + Items.Remove(removed.ItemId); + Timestamp = removed.Timestamp; + } + + public void Apply(TodoListNameChanged nameChanged) + { + Name = nameChanged.Name; + Timestamp = nameChanged.Timestamp; + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListReference.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListReference.cs new file mode 100644 index 00000000000..6847523b53c --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListReference.cs @@ -0,0 +1,4 @@ +namespace JournaledTodoList.WebApp.Grains; + +[GenerateSerializer, Immutable] +public record class TodoListReference(string Id, string Name); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs new file mode 100644 index 00000000000..e5acf54f73a --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs @@ -0,0 +1,82 @@ +using System.Collections.Immutable; +using Orleans.EventSourcing; +using Orleans.Providers; +using Orleans.Utilities; + +namespace JournaledTodoList.WebApp.Grains; + +/// +/// A "state" based Journaled Grain. It does not save the events, just the state. +/// +[LogConsistencyProvider(ProviderName = Constants.StateStorageProviderName)] +public sealed class TodoListRegistryGrain(ILogger logger) + : JournaledGrain + , ITodoListRegistryGrain +{ + private readonly ObserverManager observers = new( + TimeSpan.FromMinutes(5), + logger); + + public async Task RegisterTodoListAsync(TodoListReference todoListReference) + { + if (State.TodoLists.Contains(todoListReference)) + { + return; + } + + RaiseEvent(todoListReference); + await ConfirmEvents(); + await NotifyObservers(); + } + + // Instead of having Apply methods in TodoListRegistry, we can override + // the TransitionState method and update the state here. + protected override void TransitionState(TodoListRegistry state, TodoListReference @event) + { + // If there is an existing item with the same Id, replace it with the new item, + // ensuring the order of the items are kept. + var existingList = state.TodoLists.FirstOrDefault(x => x.Id == @event.Id); + + if (existingList is not null) + { + state.TodoLists = state.TodoLists.Replace(existingList, @event); + } + else + { + state.TodoLists = state.TodoLists.Add(@event); + } + } + + public Task> GetAllTodoListsAsync() + { + return Task.FromResult(State.TodoLists); + } + + public Task Subscribe(ITodoListRegistryObserver observer) + { + observers.Subscribe(observer, observer); + observer.OnTodoListsChanged(State.TodoLists); + return Task.CompletedTask; + } + + public Task Unsubscribe(ITodoListRegistryObserver observer) + { + observers.Unsubscribe(observer); + return Task.CompletedTask; + } + + public override Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken) + { + observers.Clear(); + return base.OnDeactivateAsync(reason, cancellationToken); + } + + private Task NotifyObservers() + => observers.Notify(observer => observer.OnTodoListsChanged(State.TodoLists)); + + [GenerateSerializer, Immutable] + public sealed class TodoListRegistry + { + public ImmutableArray TodoLists { get; set; } = []; + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/JournaledTodoList.WebApp.csproj b/orleans/JournaledTodoList/JournaledTodoList.WebApp/JournaledTodoList.WebApp.csproj new file mode 100644 index 00000000000..13fb14dcc42 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/JournaledTodoList.WebApp.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs new file mode 100644 index 00000000000..d95994b0eaf --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs @@ -0,0 +1,39 @@ +using JournaledTodoList.WebApp.Components; +using JournaledTodoList.WebApp.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddKeyedAzureTableClient("clustering"); +builder.AddKeyedAzureBlobClient("grain-state"); +builder.UseOrleans(siloBuilder => +{ + siloBuilder.AddLogStorageBasedLogConsistencyProviderAsDefault(); + siloBuilder.AddStateStorageBasedLogConsistencyProvider(name: Constants.StateStorageProviderName); +}); +builder.Services.AddScoped(); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// 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.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Properties/launchSettings.json b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Properties/launchSettings.json new file mode 100644 index 00000000000..3403eb20fed --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5169", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7111;http://localhost:5169", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs new file mode 100644 index 00000000000..15914b3315d --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs @@ -0,0 +1,96 @@ +using System.Collections.Immutable; +using System.Web; +using JournaledTodoList.WebApp.Grains; +using JournaledTodoList.WebApp.Grains.Events; + +namespace JournaledTodoList.WebApp.Services; + +public class TodoListService(IGrainFactory grainFactory) +{ + public async Task SubscribeAsync(ITodoListRegistryObserver observer) + { + var registryGrain = grainFactory.GetGrain(Constants.TodoListRegistryId); + var observerRef = grainFactory.CreateObjectReference(observer); + await registryGrain.Subscribe(observerRef); + return new Subscription(observerRef, registryGrain); + } + + public Task> GetTodoListReferencesAsync() + { + var registryGrain = grainFactory.GetGrain(Constants.TodoListRegistryId); + return registryGrain.GetAllTodoListsAsync(); + } + + public async Task CreateTodoListAsync(string listName) + { + var listId = NormalizeListName(listName); + var grain = grainFactory.GetGrain(listId); + await grain.SetNameAsync(listName); + + static string NormalizeListName(string name) + { + // Replace spaces and special characters to ensure valid URL + var normalized = name + .Trim() + .Replace(' ', '-') + .Replace('/', '-') + .Replace('\\', '-') + .ToLowerInvariant(); + + // Encode remaining bits for good measure! + return HttpUtility.UrlEncode(normalized); + } + } + + public Task GetTodoListAsync(string listId) + { + var grain = grainFactory.GetGrain(listId); + return grain.GetTodoListAsync(); + } + + public Task GetTodoListAtTimestampAsync(string listId, DateTimeOffset timestamp) + { + var grain = grainFactory.GetGrain(listId); + return grain.GetTodoListAtTimestampAsync(timestamp); + } + + public Task> GetTodoListHistoryAsync(string listId) + { + var grain = grainFactory.GetGrain(listId); + return grain.GetHistoryAsync(); + } + + public Task AddTodoItemAsync(string listId, string title) + { + var grain = grainFactory.GetGrain(listId); + return grain.AddTodoItemAsync(title); + } + + public Task UpdateTodoItemAsync(string listId, int itemId, string title) + { + var grain = grainFactory.GetGrain(listId); + return grain.UpdateTodoItemAsync(itemId, title); + } + + public Task ToggleTodoItemAsync(string listId, int itemId) + { + var grain = grainFactory.GetGrain(listId); + return grain.ToggleTodoItemAsync(itemId); + } + + public Task RemoveTodoItemAsync(string listId, int itemId) + { + var grain = grainFactory.GetGrain(listId); + return grain.RemoveTodoItemAsync(itemId); + } + + private sealed class Subscription( + ITodoListRegistryObserver observerRef, + ITodoListRegistryGrain registryGrain) : IDisposable + { + public void Dispose() + { + registryGrain.Unsubscribe(observerRef); + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.Development.json b/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.json b/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/wwwroot/app.css b/orleans/JournaledTodoList/JournaledTodoList.WebApp/wwwroot/app.css new file mode 100644 index 00000000000..53883578d45 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/wwwroot/app.css @@ -0,0 +1,38 @@ +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; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/orleans/JournaledTodoList/JournaledTodoList.sln b/orleans/JournaledTodoList/JournaledTodoList.sln new file mode 100644 index 00000000000..bc4b24da5f4 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JournaledTodoList.AppHost", "JournaledTodoList.AppHost\JournaledTodoList.AppHost.csproj", "{631A2EBD-8FE8-484F-8B28-2412E2D75874}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JournaledTodoList.ServiceDefaults", "JournaledTodoList.ServiceDefaults\JournaledTodoList.ServiceDefaults.csproj", "{02724D00-B8E6-8A72-DB7E-3D8123F39C3A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JournaledTodoList.WebApp", "JournaledTodoList.WebApp\JournaledTodoList.WebApp.csproj", "{5CD7672B-C25A-E910-16FD-342A3D36E81B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {631A2EBD-8FE8-484F-8B28-2412E2D75874}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {631A2EBD-8FE8-484F-8B28-2412E2D75874}.Debug|Any CPU.Build.0 = Debug|Any CPU + {631A2EBD-8FE8-484F-8B28-2412E2D75874}.Release|Any CPU.ActiveCfg = Release|Any CPU + {631A2EBD-8FE8-484F-8B28-2412E2D75874}.Release|Any CPU.Build.0 = Release|Any CPU + {02724D00-B8E6-8A72-DB7E-3D8123F39C3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02724D00-B8E6-8A72-DB7E-3D8123F39C3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02724D00-B8E6-8A72-DB7E-3D8123F39C3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02724D00-B8E6-8A72-DB7E-3D8123F39C3A}.Release|Any CPU.Build.0 = Release|Any CPU + {5CD7672B-C25A-E910-16FD-342A3D36E81B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CD7672B-C25A-E910-16FD-342A3D36E81B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CD7672B-C25A-E910-16FD-342A3D36E81B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CD7672B-C25A-E910-16FD-342A3D36E81B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1B7DFB36-A7DB-4D2B-BA45-9C1ECB5D444A} + EndGlobalSection +EndGlobal