From 29df5e394ed706de32e06700b1ff88d266b528dc Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Tue, 1 Sep 2026 12:56:08 -0600 Subject: [PATCH 1/4] Add web UI dashboard with health status, #90 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves a Blazor dashboard at the emulator's root URL showing whether it is running correctly, backed by two health checks also exposed at /health. Blazor rather than a separate JS framework so the UI ships inside the same assembly — no second build step and nothing extra for `dotnet tool install` to pull down. The page is written from scratch rather than from the Blazor template, which would have brought bootstrap and a sample stylesheet along for what is a handful of cards. The checks cover what actually goes wrong in the environment rather than in the emulator: whether the indexes directory can be written to (a read-only Docker volume, a tool launched from a directory without permission), and whether every index definition on disk still parses after being hand-edited. Both previously surfaced as a 500 from whichever request happened to touch them. Writability is probed with a real write, since no file attribute distinguishes a read-only bind mount from a full disk. Two accommodations were needed to put a UI at "/". OData's route components are registered at the root prefix, which puts its service document on "/" and made the dashboard's page an ambiguous match; the service document gives way, as Azure AI Search does not serve one and the SDK never requests it. MapRazorComponents also requires UseAntiforgery, which is added ahead of the API routes — it ignores requests carrying no token, so SDK writes are unaffected, and an integration test pins that. Co-Authored-By: Claude Opus 5 (1M context) --- .../DashboardIntegrationTests.cs | 114 +++++ .../HealthCheckTests.cs | 183 ++++++++ AzureSearchEmulator/Components/App.razor | 21 + .../Components/Layout/MainLayout.razor | 30 ++ .../Components/Pages/Dashboard.razor | 197 +++++++++ AzureSearchEmulator/Components/Routes.razor | 16 + AzureSearchEmulator/Components/_Imports.razor | 8 + AzureSearchEmulator/Health/EmulatorStatus.cs | 91 ++++ .../Health/IndexDefinitionsHealthCheck.cs | 50 +++ .../Health/IndexStorageHealthCheck.cs | 105 +++++ AzureSearchEmulator/Program.cs | 44 ++ .../Routing/ODataServiceDocumentConvention.cs | 38 ++ AzureSearchEmulator/wwwroot/css/dashboard.css | 411 ++++++++++++++++++ README.md | 30 ++ 14 files changed, 1338 insertions(+) create mode 100644 AzureSearchEmulator.IntegrationTests/DashboardIntegrationTests.cs create mode 100644 AzureSearchEmulator.UnitTests/HealthCheckTests.cs create mode 100644 AzureSearchEmulator/Components/App.razor create mode 100644 AzureSearchEmulator/Components/Layout/MainLayout.razor create mode 100644 AzureSearchEmulator/Components/Pages/Dashboard.razor create mode 100644 AzureSearchEmulator/Components/Routes.razor create mode 100644 AzureSearchEmulator/Components/_Imports.razor create mode 100644 AzureSearchEmulator/Health/EmulatorStatus.cs create mode 100644 AzureSearchEmulator/Health/IndexDefinitionsHealthCheck.cs create mode 100644 AzureSearchEmulator/Health/IndexStorageHealthCheck.cs create mode 100644 AzureSearchEmulator/Routing/ODataServiceDocumentConvention.cs create mode 100644 AzureSearchEmulator/wwwroot/css/dashboard.css diff --git a/AzureSearchEmulator.IntegrationTests/DashboardIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/DashboardIntegrationTests.cs new file mode 100644 index 0000000..0af895f --- /dev/null +++ b/AzureSearchEmulator.IntegrationTests/DashboardIntegrationTests.cs @@ -0,0 +1,114 @@ +using System.Net; +using Azure.Search.Documents.Indexes.Models; +using Xunit; + +namespace AzureSearchEmulator.IntegrationTests; + +/// +/// Integration tests for the dashboard and health endpoint (issue #90), run against a +/// containerized emulator. +/// +/// +/// What these are really guarding is the coexistence. Serving a UI at / meant taking the +/// root away from OData's service document and adding anti-forgery middleware in front of every +/// route, and both of those are the kind of change that works on the developer's machine and +/// breaks the API surface somewhere else. So the assertions come in pairs: the dashboard and +/// /health answer, and the endpoints the Azure SDK depends on still answer as they did. +/// +public class DashboardIntegrationTests(EmulatorFactory factory) + : IClassFixture +{ + [Fact] + public async Task Root_ServesTheDashboard() + { + await factory.WaitUntilServingAsync(); + using var client = factory.CreateHttpClient(); + + var response = await client.GetAsync("/", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + + var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + // Prerendered server-side, so the status is in the markup rather than arriving later over + // the circuit — which is also what makes it assertable without driving a browser. + Assert.Contains("Azure Search Emulator", html); + Assert.Contains("Health checks", html); + Assert.Contains("Running normally", html); + } + + [Fact] + public async Task Dashboard_StylesheetIsServed() + { + await factory.WaitUntilServingAsync(); + using var client = factory.CreateHttpClient(); + + var response = await client.GetAsync("/css/dashboard.css", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/css", response.Content.Headers.ContentType?.MediaType); + } + + /// + /// The container has a writable indexes volume and no malformed definitions, so a healthy + /// answer here is the real thing rather than a check that cannot fail — the unit tests cover + /// the failing directions. + /// + [Fact] + public async Task Health_ReportsHealthy() + { + await factory.WaitUntilServingAsync(); + using var client = factory.CreateHttpClient(); + + var response = await client.GetAsync("/health", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + /// + /// The dashboard took / from OData's service document, which sits on the same controller + /// as $metadata. Only the former was meant to go. + /// + [Fact] + public async Task Metadata_IsStillServed() + { + await factory.WaitUntilServingAsync(); + using var client = factory.CreateHttpClient(); + + var response = await client.GetAsync("/$metadata", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("SearchIndex", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + /// + /// Anti-forgery middleware runs ahead of the API routes now. It is supposed to ignore requests + /// that carry no token, but a misconfiguration would reject exactly the unauthenticated writes + /// every SDK client makes — so this drives one through the SDK rather than raw HTTP. + /// + [Fact] + public async Task ApiWrites_AreNotBlockedByAntiforgery() + { + var client = factory.CreateSearchIndexClient(); + const string indexName = "test-dashboard-antiforgery"; + + var index = new SearchIndex(indexName) + { + Fields = { new SearchField("id", SearchFieldDataType.String) { IsKey = true } } + }; + + try + { + await client.CreateIndexAsync(index, TestContext.Current.CancellationToken); + + var fetched = await client.GetIndexAsync(indexName, TestContext.Current.CancellationToken); + Assert.Equal(indexName, fetched.Value.Name); + } + finally + { + await client.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken); + } + } +} diff --git a/AzureSearchEmulator.UnitTests/HealthCheckTests.cs b/AzureSearchEmulator.UnitTests/HealthCheckTests.cs new file mode 100644 index 0000000..5086f0c --- /dev/null +++ b/AzureSearchEmulator.UnitTests/HealthCheckTests.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using AzureSearchEmulator.Health; +using AzureSearchEmulator.Models; +using AzureSearchEmulator.Repositories; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace AzureSearchEmulator.UnitTests; + +/// +/// Tests for the health checks behind the dashboard and /health (issue #90). +/// +/// +/// These run against real directories rather than an abstraction over the filesystem, because the +/// conditions the checks exist to catch — a directory that is missing, or present but not writable +/// — are filesystem states, and a mocked IFileSystem reporting them would only be testing +/// the mock's own opinion of what a failed write looks like. +/// +public class HealthCheckTests : IDisposable +{ + private readonly string _root = + Path.Join(Path.GetTempPath(), $"azsearchemu-health-{Guid.NewGuid():N}"); + + public void Dispose() + { + if (!Directory.Exists(_root)) + { + return; + } + + // A directory the writability test chmod'ed has to be made writable again or the delete + // fails and leaks the temp folder. Nothing is chmod'ed on Windows, where that test skips. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(_root, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + Directory.Delete(_root, recursive: true); + } + + private IndexStorageHealthCheck CreateStorageCheck(string directory) => + new(Options.Create(new EmulatorOptions { IndexesDirectory = directory })); + + private static Task Run(IHealthCheck check) => + check.CheckHealthAsync(new HealthCheckContext()); + + [Fact] + public async Task IndexStorage_WithWritableDirectory_IsHealthy() + { + Directory.CreateDirectory(_root); + + var result = await Run(CreateStorageCheck(_root)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(_root, result.Data["indexesDirectory"]); + } + + /// + /// The emulator creates the indexes directory on first use, so a fresh install has none. That + /// is the normal state rather than a fault, and reporting it as unhealthy would mean every + /// first run of the tool showed a red dashboard. + /// + [Fact] + public async Task IndexStorage_WhenDirectoryDoesNotYetExist_IsHealthy() + { + Directory.CreateDirectory(_root); + var notYetCreated = Path.Join(_root, "indexes"); + + var result = await Run(CreateStorageCheck(notYetCreated)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task IndexStorage_WhenNeitherDirectoryNorParentExists_IsUnhealthy() + { + var unreachable = Path.Join(_root, "no", "such", "path"); + + var result = await Run(CreateStorageCheck(unreachable)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("cannot be created", result.Description); + } + + /// + /// The case the check is really for: a Docker volume mounted read-only, or a tool launched from + /// a directory the user cannot write to. Without it the failure first appears as a 500 from + /// whichever indexing request happened to run. + /// + [Fact] + public async Task IndexStorage_WhenDirectoryIsNotWritable_IsUnhealthy() + { + // Written as an if rather than Assert.SkipWhen so the platform analyzer can see that + // SetUnixFileMode is only reached off Windows; Skip throws, which it cannot narrow on. + if (OperatingSystem.IsWindows()) + { + Assert.Skip("Unix file modes are the mechanism being used to make the directory read-only."); + return; + } + + Assert.SkipWhen(Environment.UserName == "root", + "root writes to a read-only directory regardless of its mode."); + + Directory.CreateDirectory(_root); + File.SetUnixFileMode(_root, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + var result = await Run(CreateStorageCheck(_root)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("not writable", result.Description); + } + + [Fact] + public async Task IndexStorage_LeavesNoProbeFileBehind() + { + Directory.CreateDirectory(_root); + + await Run(CreateStorageCheck(_root)); + + Assert.Empty(Directory.GetFileSystemEntries(_root)); + } + + [Fact] + public async Task IndexDefinitions_WithReadableDefinitions_ReportsTheCount() + { + Directory.CreateDirectory(_root); + WriteIndex("products"); + WriteIndex("people"); + + var result = await Run(new IndexDefinitionsHealthCheck(CreateRepository())); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(2, result.Data["indexCount"]); + } + + /// + /// Degraded rather than Unhealthy: the emulator still serves every index that parses, and the + /// checks aggregate into one overall status, so failing outright would claim the whole service + /// is down over one hand-edited file. + /// + [Fact] + public async Task IndexDefinitions_WithAMalformedDefinition_IsDegraded() + { + Directory.CreateDirectory(_root); + WriteIndex("products"); + File.WriteAllText(Path.Join(_root, "broken.index.json"), "{ this is not json"); + + var result = await Run(new IndexDefinitionsHealthCheck(CreateRepository())); + + Assert.Equal(HealthStatus.Degraded, result.Status); + } + + [Fact] + public async Task IndexDefinitions_WithNoIndexes_IsHealthy() + { + Directory.CreateDirectory(_root); + + var result = await Run(new IndexDefinitionsHealthCheck(CreateRepository())); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(0, result.Data["indexCount"]); + } + + private ISearchIndexRepository CreateRepository() => + new FileSearchIndexRepository( + new JsonSerializerOptions(JsonSerializerDefaults.Web), + Options.Create(new EmulatorOptions { IndexesDirectory = _root })); + + private void WriteIndex(string name) + { + var index = new SearchIndex + { + Name = name, + Fields = [new SearchField { Name = "Id", Type = "Edm.String", Key = true }] + }; + + File.WriteAllText( + Path.Join(_root, $"{name}.index.json"), + JsonSerializer.Serialize(index, new JsonSerializerOptions(JsonSerializerDefaults.Web))); + } +} diff --git a/AzureSearchEmulator/Components/App.razor b/AzureSearchEmulator/Components/App.razor new file mode 100644 index 0000000..8ac93a7 --- /dev/null +++ b/AzureSearchEmulator/Components/App.razor @@ -0,0 +1,21 @@ +@* Root component (issue #90). Hand-written rather than taken from the Blazor template: the + template's version pulls in bootstrap, the sample stylesheet and the blazor error UI, none of + which this dashboard uses. *@ + + + + + + + + + + + + + + + + + + diff --git a/AzureSearchEmulator/Components/Layout/MainLayout.razor b/AzureSearchEmulator/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..9bfc2f1 --- /dev/null +++ b/AzureSearchEmulator/Components/Layout/MainLayout.razor @@ -0,0 +1,30 @@ +@inherits LayoutComponentBase + +
+
+ + + + Azure Search Emulator + Local development service + + +
+ +
+ @Body +
+ + +
diff --git a/AzureSearchEmulator/Components/Pages/Dashboard.razor b/AzureSearchEmulator/Components/Pages/Dashboard.razor new file mode 100644 index 0000000..da9ec70 --- /dev/null +++ b/AzureSearchEmulator/Components/Pages/Dashboard.razor @@ -0,0 +1,197 @@ +@page "/" +@rendermode InteractiveServer +@inject EmulatorStatusService StatusService +@implements IDisposable + +Azure Search Emulator + +

Azure Search Emulator status

+ +@if (_status is null) +{ +
+ +

Checking emulator health…

+
+} +else +{ +
+
+ +
+

Emulator status

+

@StatusHeadline(_status.Status)

+
+
+
+
+
Version
+
@_status.Version
+
+
+
Environment
+
@_status.Environment
+
+
+
Uptime
+
@FormatUptime(_status.Uptime)
+
+
+
Checks took
+
@FormatDuration(_status.TotalDuration)
+
+
+
+ +
+
+

Health checks

+ +
+ +
    + @foreach (var entry in _status.Entries) + { +
  • + +
    +

    @Humanize(entry.Name)

    + @if (!string.IsNullOrWhiteSpace(entry.Description)) + { +

    @entry.Description

    + } +
    + @entry.Status +
  • + } +
+
+ +
+

Storage

+

Index definitions and their Lucene data are written here.

+

@_status.IndexesDirectory

+
+ +

+ Last checked @_status.CheckedAt.ToLocalTime().ToString("HH:mm:ss") · refreshes + automatically every @RefreshInterval.TotalSeconds seconds. +

+} + +@code { + // Long enough that an idle dashboard is not re-probing storage every second, short enough + // that a mount going read-only shows up while the user is still looking at the page. + private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(15); + + private EmulatorStatus? _status; + private bool _refreshing; + private PeriodicTimer? _timer; + private CancellationTokenSource? _cts; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(CancellationToken.None); + } + + protected override void OnAfterRender(bool firstRender) + { + // Started after the first render rather than in OnInitializedAsync, which also runs during + // the prerender pass — a timer started there would tick against a circuit that no longer + // exists once the interactive one takes over. + if (!firstRender) + { + return; + } + + _cts = new CancellationTokenSource(); + _timer = new PeriodicTimer(RefreshInterval); + _ = PollAsync(_cts.Token); + } + + private async Task PollAsync(CancellationToken cancellationToken) + { + try + { + while (await _timer!.WaitForNextTickAsync(cancellationToken)) + { + await LoadAsync(cancellationToken); + await InvokeAsync(StateHasChanged); + } + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // The page went away mid-tick: Dispose cancels the token and disposes the timer, and + // either can surface here depending on where the loop happened to be. Neither is a + // fault worth tearing down the circuit over. + } + } + + private async Task RefreshAsync() + { + _refreshing = true; + + try + { + await LoadAsync(_cts?.Token ?? CancellationToken.None); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // Navigated away while the checks were still running. + } + finally + { + _refreshing = false; + } + } + + private async Task LoadAsync(CancellationToken cancellationToken) + { + _status = await StatusService.GetStatusAsync(cancellationToken); + } + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + _timer?.Dispose(); + } + + private static string StatusClass(HealthStatus status) => status switch + { + HealthStatus.Healthy => "is-healthy", + HealthStatus.Degraded => "is-degraded", + _ => "is-unhealthy" + }; + + private static string StatusHeadline(HealthStatus status) => status switch + { + HealthStatus.Healthy => "Running normally", + HealthStatus.Degraded => "Running with problems", + _ => "Not serving requests correctly" + }; + + private static string FormatDuration(TimeSpan duration) => + duration.TotalMilliseconds < 1000 + ? $"{duration.TotalMilliseconds:0} ms" + : $"{duration.TotalSeconds:0.0} s"; + + private static string FormatUptime(TimeSpan uptime) => uptime switch + { + { TotalMinutes: < 1 } => $"{uptime.TotalSeconds:0} sec", + { TotalHours: < 1 } => $"{uptime.TotalMinutes:0} min", + { TotalDays: < 1 } => $"{uptime.Hours}h {uptime.Minutes}m", + _ => $"{(int)uptime.TotalDays}d {uptime.Hours}h" + }; + + /// Health check names are registered as identifiers; the dashboard shows prose. + private static string Humanize(string name) => name switch + { + "index-storage" => "Index storage", + "index-definitions" => "Index definitions", + _ => name + }; +} diff --git a/AzureSearchEmulator/Components/Routes.razor b/AzureSearchEmulator/Components/Routes.razor new file mode 100644 index 0000000..b28c13f --- /dev/null +++ b/AzureSearchEmulator/Components/Routes.razor @@ -0,0 +1,16 @@ + + + + + + + + Not found +
+

Not found

+

There is nothing at this address.

+

Back to the dashboard

+
+
+
+
diff --git a/AzureSearchEmulator/Components/_Imports.razor b/AzureSearchEmulator/Components/_Imports.razor new file mode 100644 index 0000000..fff5ff8 --- /dev/null +++ b/AzureSearchEmulator/Components/_Imports.razor @@ -0,0 +1,8 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.Extensions.Diagnostics.HealthChecks +@using AzureSearchEmulator.Components +@using AzureSearchEmulator.Components.Layout +@using AzureSearchEmulator.Health diff --git a/AzureSearchEmulator/Health/EmulatorStatus.cs b/AzureSearchEmulator/Health/EmulatorStatus.cs new file mode 100644 index 0000000..19d2c61 --- /dev/null +++ b/AzureSearchEmulator/Health/EmulatorStatus.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace AzureSearchEmulator.Health; + +/// +/// The health report plus the handful of facts about the running emulator the dashboard shows +/// alongside it (issue #90). +/// +public record EmulatorStatus( + HealthStatus Status, + TimeSpan TotalDuration, + IReadOnlyList Entries, + string Version, + string Environment, + string IndexesDirectory, + TimeSpan Uptime, + DateTimeOffset CheckedAt); + +public record EmulatorStatusEntry( + string Name, + HealthStatus Status, + string? Description, + TimeSpan Duration, + IReadOnlyDictionary Data); + +/// +/// Produces an for the dashboard. +/// +/// +/// The dashboard runs in-process, so it calls directly instead of +/// fetching its own /health endpoint over HTTP. Going back out through the network stack +/// would mean the page needed to know its own externally reachable URL — which, behind the +/// container's port mapping or an Aspire proxy, it does not — to report on checks that are sitting +/// in the same process. +/// +public class EmulatorStatusService( + HealthCheckService healthCheckService, + IHostEnvironment hostEnvironment, + Microsoft.Extensions.Options.IOptions options, + TimeProvider timeProvider) +{ + private static readonly string AssemblyVersion = GetAssemblyVersion(); + + private static readonly long StartedAtTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); + + public async Task GetStatusAsync(CancellationToken cancellationToken = default) + { + var report = await healthCheckService.CheckHealthAsync(cancellationToken); + + var entries = report.Entries + .Select(i => new EmulatorStatusEntry( + i.Key, + i.Value.Status, + // A failing check's exception message is the useful part and the description is + // often null, so fall back to it rather than showing a bare "Unhealthy". + i.Value.Description ?? i.Value.Exception?.Message, + i.Value.Duration, + i.Value.Data)) + .OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new EmulatorStatus( + report.Status, + report.TotalDuration, + entries, + AssemblyVersion, + hostEnvironment.EnvironmentName, + options.Value.IndexesDirectory, + System.Diagnostics.Stopwatch.GetElapsedTime(StartedAtTimestamp), + timeProvider.GetUtcNow()); + } + + private static string GetAssemblyVersion() + { + var assembly = typeof(EmulatorStatusService).Assembly; + + var informational = assembly.GetCustomAttribute() + ?.InformationalVersion; + + if (!string.IsNullOrEmpty(informational)) + { + // The informational version carries a "+" suffix when the build has source + // link enabled; the dashboard wants the version, not the provenance. + var plus = informational.IndexOf('+'); + return plus < 0 ? informational : informational[..plus]; + } + + return assembly.GetName().Version?.ToString() ?? "unknown"; + } +} diff --git a/AzureSearchEmulator/Health/IndexDefinitionsHealthCheck.cs b/AzureSearchEmulator/Health/IndexDefinitionsHealthCheck.cs new file mode 100644 index 0000000..0661439 --- /dev/null +++ b/AzureSearchEmulator/Health/IndexDefinitionsHealthCheck.cs @@ -0,0 +1,50 @@ +using AzureSearchEmulator.Repositories; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace AzureSearchEmulator.Health; + +/// +/// Reports whether every index definition on disk still deserializes (issue #90). +/// +/// +/// The definitions are plain JSON files in a directory users are told about and mount as a volume, +/// so hand-editing one is an expected thing to do. A malformed file does not surface until some +/// request happens to enumerate it, and then it surfaces as a 500 from an unrelated route — +/// throws partway through, taking the listing with it. +/// Reporting it here names the file instead. +/// +/// Degraded rather than Unhealthy: the emulator is still serving every index that does parse, and +/// the checks are aggregated into one overall status, so failing outright would claim the service +/// is down when one stray file is the whole of the problem. +/// +/// +public class IndexDefinitionsHealthCheck(ISearchIndexRepository searchIndexRepository) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var count = 0; + + try + { + await foreach (var index in searchIndexRepository.GetAll().WithCancellation(cancellationToken)) + { + _ = index; + count++; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException + or System.Text.Json.JsonException) + { + return HealthCheckResult.Degraded( + $"One or more index definitions could not be read: {ex.Message}", + ex, + new Dictionary { ["indexesRead"] = count }); + } + + return HealthCheckResult.Healthy( + count == 1 ? "1 index definition loaded." : $"{count} index definitions loaded.", + new Dictionary { ["indexCount"] = count }); + } +} diff --git a/AzureSearchEmulator/Health/IndexStorageHealthCheck.cs b/AzureSearchEmulator/Health/IndexStorageHealthCheck.cs new file mode 100644 index 0000000..f5d216f --- /dev/null +++ b/AzureSearchEmulator/Health/IndexStorageHealthCheck.cs @@ -0,0 +1,105 @@ +using AzureSearchEmulator.Repositories; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace AzureSearchEmulator.Health; + +/// +/// Reports whether the emulator can actually read and write the directory its indexes live in +/// (issue #90). +/// +/// +/// This is the check worth having. Everything the emulator does is backed by that directory, and +/// the ways it goes wrong are all environmental rather than code faults: a Docker volume mounted +/// read-only, a dotnet tool launched from a directory the user cannot write to, a path +/// configured to somewhere that does not exist. Each produces failures at index-creation or +/// indexing time that read like emulator bugs, so surfacing it as health turns a confusing 500 +/// into a stated cause. +/// +/// Writability is probed rather than inferred from file attributes, because on the platforms the +/// emulator runs on the attributes do not answer the question — a POSIX mode bit, an ACL, a +/// read-only bind mount and a full disk all present differently, and only an actual write covers +/// them all. +/// +/// +public class IndexStorageHealthCheck(IOptions options) : IHealthCheck +{ + private readonly EmulatorOptions _options = options.Value; + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var directory = _options.IndexesDirectory; + + var data = new Dictionary + { + ["indexesDirectory"] = directory + }; + + if (!Directory.Exists(directory)) + { + // Not a failure: the directory is created on first use, so its absence on a fresh + // install is the normal state rather than a fault. What matters is whether the + // emulator would be able to create it, which is what the parent's writability says. + var parent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(directory)); + + if (parent == null || !Directory.Exists(parent)) + { + return HealthCheckResult.Unhealthy( + $"The indexes directory '{directory}' does not exist and neither does its parent, so it cannot be created.", + data: data); + } + + var parentProbe = await TryWrite(parent, cancellationToken); + + return parentProbe == null + ? HealthCheckResult.Healthy("No indexes have been created yet.", data) + : HealthCheckResult.Unhealthy( + $"The indexes directory '{directory}' does not exist and cannot be created: {parentProbe}", + data: data); + } + + var probe = await TryWrite(directory, cancellationToken); + + if (probe != null) + { + return HealthCheckResult.Unhealthy( + $"The indexes directory '{directory}' is not writable: {probe}", + data: data); + } + + return HealthCheckResult.Healthy("The indexes directory is readable and writable.", data); + } + + /// null when the write succeeded, otherwise a description of why it did not. + private static async Task TryWrite(string directory, CancellationToken cancellationToken) + { + // A uniquely named file, so two emulators sharing an indexes volume — or one being probed + // concurrently — cannot collide and report each other's probe as a failure. + var path = Path.Join(directory, $".healthcheck-{Guid.NewGuid():N}.tmp"); + + try + { + await File.WriteAllTextAsync(path, string.Empty, cancellationToken); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return ex.Message; + } + finally + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The probe already answered the question it was asked; a leftover empty file is + // not worth failing health over, and it is excluded from the *.index.json glob + // the repository enumerates. + } + } + } +} diff --git a/AzureSearchEmulator/Program.cs b/AzureSearchEmulator/Program.cs index 8c73365..a33c729 100644 --- a/AzureSearchEmulator/Program.cs +++ b/AzureSearchEmulator/Program.cs @@ -1,9 +1,12 @@ using System.Text.Json; using AzureSearchEmulator; +using AzureSearchEmulator.Components; using AzureSearchEmulator.ErrorHandling; +using AzureSearchEmulator.Health; using AzureSearchEmulator.Indexing; using AzureSearchEmulator.Models; using AzureSearchEmulator.Repositories; +using AzureSearchEmulator.Routing; using AzureSearchEmulator.SearchData; using AzureSearchEmulator.Searching; using Microsoft.AspNetCore.Http.Extensions; @@ -71,6 +74,10 @@ // as a filter rather than applied at each call site so every existing rejection — // and any added later — carries the shape the SDK reads (issue #40). options.Filters.Add(); + + // Frees "/" for the dashboard; see the convention for why the service document is the + // endpoint that gives way (issue #90). + options.Conventions.Add(new ODataServiceDocumentConvention()); }) .AddJsonOptions(options => { @@ -130,6 +137,23 @@ builder.Services.AddTransient(); builder.Services.AddSingleton(); +// Health checks, surfaced both at /health for a monitor and on the dashboard for a human +// (issue #90). The names are the identifiers the dashboard maps to prose. +builder.Services.AddHealthChecks() + .AddCheck("index-storage") + .AddCheck("index-definitions"); + +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddScoped(); + +// The dashboard (issue #90). Blazor rather than a separate SPA so the whole UI ships inside the +// same assembly as the emulator — no second build step, no static bundle to keep in sync, and +// nothing extra for `dotnet tool install` to pull down. The interactive server render mode means +// the status panel refreshes over the existing circuit instead of the page needing to know its +// own externally reachable URL to poll itself. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + var app = builder.Build(); // Outermost, so it also covers the model binding and OData formatter work that runs before a @@ -194,8 +218,28 @@ app.UseRouting(); +// Serves wwwroot, which holds the dashboard's stylesheet and nothing else. Registered after +// UseRouting so these requests still pass through the request logging above rather than being +// short-circuited ahead of it. +app.UseStaticFiles(); + +// Required by MapRazorComponents, which stamps anti-forgery metadata onto its endpoints. It only +// validates tokens on the form posts and interactive requests that carry them, so the emulator's +// own API routes are unaffected — a client posting a document batch has no token and is not asked +// for one. +app.UseAntiforgery(); + app.MapControllers(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +// The machine-readable half of the same checks the dashboard renders. Kept separate from +// /servicestats, which Aspire already probes: that route exists to imitate Azure's API surface and +// answers 200 as long as the process is up, whereas this one answers for the emulator's actual +// ability to serve — a read-only indexes volume is a 200 there and Unhealthy here. +app.MapHealthChecks("/health"); + await app.RunAsync(); return; diff --git a/AzureSearchEmulator/Routing/ODataServiceDocumentConvention.cs b/AzureSearchEmulator/Routing/ODataServiceDocumentConvention.cs new file mode 100644 index 0000000..f87c73f --- /dev/null +++ b/AzureSearchEmulator/Routing/ODataServiceDocumentConvention.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.OData.Routing.Controllers; + +namespace AzureSearchEmulator.Routing; + +/// +/// Drops OData's service-document action so the dashboard can own / (issue #90). +/// +/// +/// AddRouteComponents("", model) registers OData's built-in +/// at the route prefix, which puts on /. +/// The dashboard's @page "/" lands on the same address, and routing refuses to choose between +/// them — the request fails with an AmbiguousMatchException rather than either endpoint winning. +/// +/// The service document is the one to give up. Azure AI Search does not serve one: its root answers +/// 404, so nothing written against the real service can be relying on it, and the Azure SDK never +/// requests it. It exists here only because registering the OData route components at the root +/// prefix brings it along. /$metadata is a separate action on the same controller and stays +/// registered. +/// +/// +public class ODataServiceDocumentConvention : IApplicationModelConvention +{ + public void Apply(ApplicationModel application) + { + foreach (var controller in application.Controllers + .Where(i => i.ControllerType == typeof(MetadataController).GetTypeInfo())) + { + foreach (var action in controller.Actions + .Where(i => i.ActionName == nameof(MetadataController.GetServiceDocument)) + .ToList()) + { + controller.Actions.Remove(action); + } + } + } +} diff --git a/AzureSearchEmulator/wwwroot/css/dashboard.css b/AzureSearchEmulator/wwwroot/css/dashboard.css new file mode 100644 index 0000000..54b1b55 --- /dev/null +++ b/AzureSearchEmulator/wwwroot/css/dashboard.css @@ -0,0 +1,411 @@ +/* Dashboard styling (issue #90). + * + * Written from scratch rather than starting from the Blazor template's bootstrap bundle: the + * dashboard is a handful of cards, and shipping a CSS framework to lay them out would add far + * more to a `dotnet tool` package than it saves. Colours are declared as tokens on :root and + * re-declared under prefers-color-scheme so the page follows the developer's OS theme. */ + +:root { + color-scheme: light dark; + + --bg: #f5f6f8; + --bg-elevated: #ffffff; + --border: #e2e5ea; + --border-strong: #cdd2da; + --text: #16191f; + --text-muted: #5c6470; + --accent: #0b62d0; + + --healthy: #10794a; + --healthy-soft: #e6f4ec; + --degraded: #8a5a00; + --degraded-soft: #fdf2dd; + --unhealthy: #b3261e; + --unhealthy-soft: #fceceb; + + --radius: 14px; + --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 8px 24px rgb(16 24 40 / 6%); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #101318; + --bg-elevated: #181c23; + --border: #262c36; + --border-strong: #333b47; + --text: #e8eaed; + --text-muted: #9aa3b0; + --accent: #6ba6ff; + + --healthy: #5cd39a; + --healthy-soft: #10251c; + --degraded: #e8b563; + --degraded-soft: #2a2113; + --unhealthy: #f2857e; + --unhealthy-soft: #2b1615; + + --shadow: 0 1px 2px rgb(0 0 0 / 40%), 0 8px 24px rgb(0 0 0 / 30%); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +h1, h2 { + margin: 0; + letter-spacing: -0.01em; +} + +h2 { + font-size: 1rem; + font-weight: 600; +} + +p { + margin: 0; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +/* Layout ------------------------------------------------------------------ */ + +.shell { + max-width: 880px; + margin: 0 auto; + padding: 0 24px 48px; + min-height: 100vh; + display: flex; + flex-direction: column; + gap: 24px; +} + +.masthead { + padding: 28px 0 4px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: inherit; +} + +.brand-mark { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 11px; + background: linear-gradient(140deg, #0b62d0, #2aa5e0); + color: #fff; + box-shadow: var(--shadow); +} + +.brand-mark svg { + width: 21px; + height: 21px; +} + +.brand-text { + display: flex; + flex-direction: column; + line-height: 1.25; +} + +.brand-text strong { + font-size: 1.0625rem; + font-weight: 650; +} + +.brand-text small { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.content { + display: flex; + flex-direction: column; + gap: 20px; + flex: 1; +} + +.footwell { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + justify-content: space-between; + padding-top: 20px; + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.8125rem; +} + +/* Hero -------------------------------------------------------------------- */ + +.hero { + display: flex; + flex-wrap: wrap; + gap: 24px; + justify-content: space-between; + align-items: center; + padding: 24px; + border: 1px solid var(--border); + border-left: 4px solid var(--status-color); + border-radius: var(--radius); + background: var(--bg-elevated); + box-shadow: var(--shadow); +} + +.hero-status { + display: flex; + align-items: center; + gap: 14px; +} + +.hero-eyebrow { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.hero-headline { + font-size: 1.375rem; + font-weight: 650; + letter-spacing: -0.015em; +} + +.hero-facts { + display: flex; + flex-wrap: wrap; + gap: 8px 28px; + margin: 0; +} + +.hero-facts dt { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.hero-facts dd { + margin: 2px 0 0; + font-size: 0.9375rem; + font-variant-numeric: tabular-nums; +} + +/* Panels ------------------------------------------------------------------ */ + +.panel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px 24px 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-elevated); + box-shadow: var(--shadow); +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.panel-loading { + flex-direction: row; + align-items: center; + gap: 12px; +} + +.muted { + color: var(--text-muted); + font-size: 0.875rem; +} + +.path { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--bg); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.8125rem; + overflow-wrap: anywhere; +} + +/* Checks ------------------------------------------------------------------ */ + +.checks { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 11px; + background: var(--status-soft); +} + +.check-body { + flex: 1; + min-width: 0; +} + +.check-name { + font-weight: 600; +} + +.check-description { + color: var(--text-muted); + font-size: 0.875rem; + overflow-wrap: anywhere; +} + +.badge { + flex-shrink: 0; + padding: 3px 10px; + border-radius: 999px; + background: var(--bg-elevated); + border: 1px solid var(--border-strong); + color: var(--status-color); + font-size: 0.75rem; + font-weight: 650; + letter-spacing: 0.02em; +} + +/* Status colours ---------------------------------------------------------- */ + +.is-healthy { + --status-color: var(--healthy); + --status-soft: var(--healthy-soft); +} + +.is-degraded { + --status-color: var(--degraded); + --status-soft: var(--degraded-soft); +} + +.is-unhealthy { + --status-color: var(--unhealthy); + --status-soft: var(--unhealthy-soft); +} + +.status-dot { + flex-shrink: 0; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--status-color); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--status-color) 22%, transparent); +} + +/* Controls ---------------------------------------------------------------- */ + +.button { + padding: 7px 14px; + border: 1px solid var(--border-strong); + border-radius: 9px; + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.button:disabled { + opacity: 0.6; + cursor: default; +} + +.link { + color: var(--accent); + text-decoration: none; +} + +.link:hover { + text-decoration: underline; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.timestamp { + color: var(--text-muted); + font-size: 0.8125rem; + text-align: center; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 700ms linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation-duration: 3s; + } +} + +@media (max-width: 620px) { + .shell { + padding: 0 16px 32px; + } + + .hero { + align-items: flex-start; + } +} diff --git a/README.md b/README.md index 5a8edad..221cd1e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,33 @@ You can also run the emulator [with Docker](#building-and-running-with-docker), 2. Open AzureSearchEmulator.sln in Visual Studio 2026, Rider, or Visual Studio Code with C# Dev Kit and run it, or cd to the `AzureSearchEmulator` folder and run `dotnet run` from the command-line. +## Web UI + +Open the emulator's base address in a browser — http://localhost:5123 by default — for a +dashboard showing whether the emulator is running correctly: the version and environment it is +running as, how long it has been up, where it is storing indexes, and the result of each health +check. It refreshes on its own every few seconds. + +The emulator is a local development tool, so the UI has no authentication. Do not expose it on an +address other machines can reach. + +The same checks are available at `/health`, which answers `Healthy`, `Degraded`, or `Unhealthy` as +plain text with a matching status code, for scripts and container health probes: + +```bash +curl http://localhost:5123/health +``` + +The checks cover the things that go wrong in the environment rather than in the emulator: whether +the indexes directory can actually be written to (a read-only Docker volume, or a directory the +tool was launched from without permission), and whether every index definition on disk still +parses after being hand-edited. A malformed definition reports `Degraded` and names the problem +rather than failing an unrelated request later. + +Note that this replaces the OData service document that used to be served at the base address. +Azure AI Search does not serve one either, and the Azure SDK never requests it; `/$metadata` is +unchanged. + ## Features This project aims to be a nearly complete, API-compatible emulator of Azure Search for your local development environment, @@ -141,6 +168,9 @@ Currently, there is support (to varying degrees) for the following Azure Search * Get service stats (mostly dummy values) * [Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/azureai/azureai-search-document-integration) for example uses servicestats route as a health check endpoint. +Beyond the Azure Search API surface, the emulator also serves a web UI and a health endpoint of its +own (see Web UI above). + ### Suggesters and autocomplete An index may declare `suggesters`, each naming the searchable fields that typeahead queries From 7b3b72e3b1c259fb44e3c0bd19fe4068328ea558 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Tue, 1 Sep 2026 15:48:26 -0600 Subject: [PATCH 2/4] Remove the CodeQL workflow in favor of default setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL has failed on every run since the upgrade to Aspire 13.5.3, on main as well as on branches, and for two stacked reasons. The visible one was ASPIRE009: autobuild builds AzureSearchEmulator.Aspire.DemoAppHost, whose AppHost SDK resolves an Aspire CLI bundle during build and errors out when it cannot find one, after which autobuild gives up with "Could not auto-detect a suitable build method". Building the solution explicitly gets past that, but only far enough to reach the real blocker: the repository has CodeQL default setup enabled, covering csharp and actions since 2026-08-10, and GitHub refuses SARIF from an advanced configuration while it is on — "CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled". The two are mutually exclusive, so the checked-in workflow could not have succeeded regardless of how it built. Default setup already scans this repository, so the workflow file is redundant and is removed rather than repaired. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/codeql-analysis.yml | 70 --------------------------- 1 file changed, 70 deletions(-) delete mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index c7b426a..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,70 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ main ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] - schedule: - - cron: '39 4 * * 4' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'csharp' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support - - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@a35ac6e6798d72df5475948b28efb89edc2e19ca # v4.37.9 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@a35ac6e6798d72df5475948b28efb89edc2e19ca # v4.37.9 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@a35ac6e6798d72df5475948b28efb89edc2e19ca # v4.37.9 From ebea0c99580552c6cebf40bc9978de640d450df0 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Mon, 26 Oct 2026 11:05:17 -0600 Subject: [PATCH 3/4] Upgrade Aspire to 13.6.4 --- .../AzureSearchEmulator.Aspire.DemoAppHost.csproj | 2 +- AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj | 2 +- aspire.config.json | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 aspire.config.json diff --git a/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj b/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj index ce190a9..cf7a37d 100644 --- a/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj +++ b/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj b/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj index 4abdfb9..f3fc36e 100644 --- a/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj +++ b/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj @@ -28,7 +28,7 @@ - + diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 0000000..c3877cb --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj" + } +} \ No newline at end of file From 9ebef4bd4e79d107e79a30c963ba633a0096f91c Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Mon, 26 Oct 2026 11:14:40 -0600 Subject: [PATCH 4/4] Fix issue building on .NET 11 RC2 --- .../AzureSearchEmulator.IntegrationTests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj b/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj index 0fd699f..cc2135b 100644 --- a/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj +++ b/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj @@ -1,4 +1,4 @@ - + net10.0