From a156945e0d8c990ce3059469c985bae9b498b0c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Sep 2025 22:28:44 +0000 Subject: [PATCH 1/3] Initial plan From 59156f0cecfef07965a12dc7ae090486a72c0309 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Sep 2025 22:48:32 +0000 Subject: [PATCH 2/3] Complete Typesense search integration with Docker Compose configuration Co-authored-by: BenjaminMichaelis <22186029+BenjaminMichaelis@users.noreply.github.com> --- .../Controllers/SearchController.cs | 115 ++++++ EssentialCSharp.Web/Models/SearchModels.cs | 48 +++ EssentialCSharp.Web/Program.cs | 15 + .../Services/ContentIndexingService.cs | 186 +++++++++ .../Services/ITypesenseSearchService.cs | 13 + .../Services/SearchIndexingHostedService.cs | 63 +++ .../Services/TypesenseOptions.cs | 12 + .../Services/TypesenseSearchService.cs | 249 ++++++++++++ .../Views/Shared/_Layout.cshtml | 6 +- EssentialCSharp.Web/appsettings.json | 7 + .../wwwroot/css/typesense-search.css | 371 ++++++++++++++++++ .../wwwroot/js/typesenseSearch.js | 290 ++++++++++++++ TYPESENSE_INTEGRATION.md | 250 ++++++++++++ docker-compose.yml | 63 +++ 14 files changed, 1684 insertions(+), 4 deletions(-) create mode 100644 EssentialCSharp.Web/Controllers/SearchController.cs create mode 100644 EssentialCSharp.Web/Models/SearchModels.cs create mode 100644 EssentialCSharp.Web/Services/ContentIndexingService.cs create mode 100644 EssentialCSharp.Web/Services/ITypesenseSearchService.cs create mode 100644 EssentialCSharp.Web/Services/SearchIndexingHostedService.cs create mode 100644 EssentialCSharp.Web/Services/TypesenseOptions.cs create mode 100644 EssentialCSharp.Web/Services/TypesenseSearchService.cs create mode 100644 EssentialCSharp.Web/wwwroot/css/typesense-search.css create mode 100644 EssentialCSharp.Web/wwwroot/js/typesenseSearch.js create mode 100644 TYPESENSE_INTEGRATION.md create mode 100644 docker-compose.yml diff --git a/EssentialCSharp.Web/Controllers/SearchController.cs b/EssentialCSharp.Web/Controllers/SearchController.cs new file mode 100644 index 00000000..0358bc1b --- /dev/null +++ b/EssentialCSharp.Web/Controllers/SearchController.cs @@ -0,0 +1,115 @@ +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace EssentialCSharp.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +[EnableRateLimiting("SearchEndpoint")] +public class SearchController : ControllerBase +{ + private readonly ITypesenseSearchService _searchService; + private readonly ILogger _logger; + + public SearchController(ITypesenseSearchService searchService, ILogger logger) + { + _searchService = searchService; + _logger = logger; + } + + /// + /// Search for content using Typesense + /// + /// The search request parameters + /// Cancellation token + /// Search results + [HttpPost] + public async Task Search([FromBody] SearchRequest request, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.Query)) + { + return BadRequest(new { error = "Search query cannot be empty." }); + } + + if (request.Query.Length > 500) + { + return BadRequest(new { error = "Search query is too long. Maximum 500 characters." }); + } + + try + { + var result = await _searchService.SearchAsync( + request.Query, + request.Page, + Math.Min(request.PerPage, 50), // Limit max results per page + cancellationToken); + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Search failed for query: {Query}", request.Query); + return StatusCode(500, new { error = "Search service temporarily unavailable." }); + } + } + + /// + /// Search for content using GET method for simple queries + /// + /// Search query + /// Page number (default: 1) + /// Results per page (default: 10, max: 50) + /// Cancellation token + /// Search results + [HttpGet] + public async Task Search( + [FromQuery] string q, + [FromQuery] int page = 1, + [FromQuery] int perPage = 10, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(q)) + { + return BadRequest(new { error = "Search query cannot be empty." }); + } + + var request = new SearchRequest + { + Query = q, + Page = Math.Max(1, page), + PerPage = Math.Min(Math.Max(1, perPage), 50) + }; + + return await Search(request, cancellationToken); + } + + /// + /// Get search health status + /// + /// Cancellation token + /// Health status + [HttpGet("health")] + public async Task Health(CancellationToken cancellationToken = default) + { + try + { + var isHealthy = await _searchService.IsHealthyAsync(cancellationToken); + + if (isHealthy) + { + return Ok(new { status = "healthy", timestamp = DateTime.UtcNow }); + } + else + { + return StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Health check failed"); + return StatusCode(503, new { status = "error", timestamp = DateTime.UtcNow, error = ex.Message }); + } + } +} \ No newline at end of file diff --git a/EssentialCSharp.Web/Models/SearchModels.cs b/EssentialCSharp.Web/Models/SearchModels.cs new file mode 100644 index 00000000..9b63821e --- /dev/null +++ b/EssentialCSharp.Web/Models/SearchModels.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace EssentialCSharp.Web.Models; + +public class SearchDocument +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("chapter")] + public string Chapter { get; set; } = string.Empty; + + [JsonPropertyName("section")] + public string Section { get; set; } = string.Empty; + + [JsonPropertyName("tags")] + public List Tags { get; set; } = []; + + [JsonPropertyName("created_at")] + public long CreatedAt { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); +} + +public class SearchResult +{ + public List Results { get; set; } = []; + public int TotalCount { get; set; } + public int Page { get; set; } + public int PerPage { get; set; } + public double SearchTimeMs { get; set; } + public string Query { get; set; } = string.Empty; +} + +public class SearchRequest +{ + public string Query { get; set; } = string.Empty; + public int Page { get; set; } = 1; + public int PerPage { get; set; } = 10; + public List Filters { get; set; } = []; +} \ No newline at end of file diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 1b83f672..c704aa26 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -153,8 +153,15 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddScoped(); + // Add Typesense search services + builder.Services.Configure( + builder.Configuration.GetSection(TypesenseOptions.SectionName)); + builder.Services.AddHttpClient(); + builder.Services.AddScoped(); + // Add AI Chat services if (!builder.Environment.IsDevelopment()) { @@ -198,6 +205,14 @@ private static void Main(string[] args) rateLimiterOptions.QueueLimit = 0; // No queuing for anonymous users }); + options.AddFixedWindowLimiter("SearchEndpoint", rateLimiterOptions => + { + rateLimiterOptions.PermitLimit = 50; // search requests per window (higher limit for search) + rateLimiterOptions.Window = TimeSpan.FromMinutes(1); // minute window + rateLimiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + rateLimiterOptions.QueueLimit = 0; // No queuing for immediate response + }); + // Custom response when rate limit is exceeded options.OnRejected = async (context, cancellationToken) => { diff --git a/EssentialCSharp.Web/Services/ContentIndexingService.cs b/EssentialCSharp.Web/Services/ContentIndexingService.cs new file mode 100644 index 00000000..7ed12ba8 --- /dev/null +++ b/EssentialCSharp.Web/Services/ContentIndexingService.cs @@ -0,0 +1,186 @@ +using EssentialCSharp.Web.Models; +using HtmlAgilityPack; +using System.Text.RegularExpressions; + +namespace EssentialCSharp.Web.Services; + +public interface IContentIndexingService +{ + Task IndexAllContentAsync(CancellationToken cancellationToken = default); + Task IndexSiteMappingAsync(SiteMapping siteMapping, CancellationToken cancellationToken = default); +} + +public class ContentIndexingService : IContentIndexingService +{ + private readonly ITypesenseSearchService _searchService; + private readonly ISiteMappingService _siteMappingService; + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + + public ContentIndexingService( + ITypesenseSearchService searchService, + ISiteMappingService siteMappingService, + IWebHostEnvironment environment, + ILogger logger) + { + _searchService = searchService; + _siteMappingService = siteMappingService; + _environment = environment; + _logger = logger; + } + + public async Task IndexAllContentAsync(CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("Starting to index all content"); + + // Initialize the collection if it doesn't exist + if (!await _searchService.InitializeCollectionAsync(cancellationToken)) + { + _logger.LogError("Failed to initialize Typesense collection"); + return false; + } + + var documents = new List(); + + foreach (var siteMapping in _siteMappingService.SiteMappings) + { + var document = await CreateSearchDocumentAsync(siteMapping); + if (document != null) + { + documents.Add(document); + } + } + + if (documents.Count > 0) + { + var success = await _searchService.IndexDocumentsAsync(documents, cancellationToken); + _logger.LogInformation("Indexed {Count} documents, success: {Success}", documents.Count, success); + return success; + } + + _logger.LogWarning("No documents to index"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to index all content"); + return false; + } + } + + public async Task IndexSiteMappingAsync(SiteMapping siteMapping, CancellationToken cancellationToken = default) + { + try + { + var document = await CreateSearchDocumentAsync(siteMapping); + if (document == null) + { + return false; + } + + return await _searchService.IndexDocumentAsync(document, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to index site mapping {Key}", siteMapping.PrimaryKey); + return false; + } + } + + private async Task CreateSearchDocumentAsync(SiteMapping siteMapping) + { + try + { + var filePath = Path.Combine(_environment.ContentRootPath, Path.Combine(siteMapping.PagePath)); + if (!File.Exists(filePath)) + { + _logger.LogWarning("File not found: {FilePath}", filePath); + return null; + } + + var htmlContent = await File.ReadAllTextAsync(filePath); + var doc = new HtmlDocument(); + doc.LoadHtml(htmlContent); + + // Extract content from body + var bodyNode = doc.DocumentNode.SelectSingleNode("//body"); + if (bodyNode == null) + { + _logger.LogWarning("No body content found in {FilePath}", filePath); + return null; + } + + // Remove script and style elements + var scriptsAndStyles = bodyNode.SelectNodes("//script | //style"); + if (scriptsAndStyles != null) + { + foreach (var node in scriptsAndStyles) + { + node.Remove(); + } + } + + // Extract plain text content + var textContent = bodyNode.InnerText; + var cleanContent = CleanTextContent(textContent); + + // Create tags based on the content + var tags = new List(); + if (!string.IsNullOrEmpty(siteMapping.ChapterTitle)) + { + tags.Add($"chapter-{siteMapping.ChapterNumber}"); + } + + // Extract URL from the first key + var url = $"/{siteMapping.Keys.First()}"; + if (!string.IsNullOrEmpty(siteMapping.AnchorId)) + { + url += $"#{siteMapping.AnchorId}"; + } + + return new SearchDocument + { + Id = siteMapping.PrimaryKey, + Title = siteMapping.RawHeading ?? siteMapping.ChapterTitle ?? "Unknown", + Content = cleanContent, + Url = url, + Chapter = $"Chapter {siteMapping.ChapterNumber}: {siteMapping.ChapterTitle}", + Section = siteMapping.RawHeading ?? string.Empty, + Tags = tags, + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create search document for {Key}", siteMapping.PrimaryKey); + return null; + } + } + + private static string CleanTextContent(string htmlText) + { + if (string.IsNullOrEmpty(htmlText)) + { + return string.Empty; + } + + // Decode HTML entities + var decodedText = HtmlEntity.DeEntitize(htmlText); + + // Remove extra whitespace and normalize line breaks + var cleanText = Regex.Replace(decodedText, @"\s+", " "); + + // Remove leading/trailing whitespace + cleanText = cleanText.Trim(); + + // Limit content length for search indexing (Typesense has limits) + if (cleanText.Length > 10000) + { + cleanText = cleanText[..10000] + "..."; + } + + return cleanText; + } +} \ No newline at end of file diff --git a/EssentialCSharp.Web/Services/ITypesenseSearchService.cs b/EssentialCSharp.Web/Services/ITypesenseSearchService.cs new file mode 100644 index 00000000..ce1c7059 --- /dev/null +++ b/EssentialCSharp.Web/Services/ITypesenseSearchService.cs @@ -0,0 +1,13 @@ +using EssentialCSharp.Web.Models; + +namespace EssentialCSharp.Web.Services; + +public interface ITypesenseSearchService +{ + Task SearchAsync(string query, int page = 1, int perPage = 10, CancellationToken cancellationToken = default); + Task IndexDocumentAsync(SearchDocument document, CancellationToken cancellationToken = default); + Task IndexDocumentsAsync(IEnumerable documents, CancellationToken cancellationToken = default); + Task DeleteDocumentAsync(string id, CancellationToken cancellationToken = default); + Task InitializeCollectionAsync(CancellationToken cancellationToken = default); + Task IsHealthyAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/EssentialCSharp.Web/Services/SearchIndexingHostedService.cs b/EssentialCSharp.Web/Services/SearchIndexingHostedService.cs new file mode 100644 index 00000000..3829c4c7 --- /dev/null +++ b/EssentialCSharp.Web/Services/SearchIndexingHostedService.cs @@ -0,0 +1,63 @@ +namespace EssentialCSharp.Web.Services; + +public class SearchIndexingHostedService : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public SearchIndexingHostedService(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting search indexing service"); + + // Use a background task to avoid blocking startup + _ = Task.Run(async () => + { + try + { + // Wait a bit for the application to fully start + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + + using var scope = _serviceProvider.CreateScope(); + var searchService = scope.ServiceProvider.GetRequiredService(); + var indexingService = scope.ServiceProvider.GetRequiredService(); + + // Check if Typesense is healthy + var isHealthy = await searchService.IsHealthyAsync(cancellationToken); + if (!isHealthy) + { + _logger.LogWarning("Typesense is not healthy, skipping content indexing"); + return; + } + + // Index all content + var success = await indexingService.IndexAllContentAsync(cancellationToken); + if (success) + { + _logger.LogInformation("Successfully completed content indexing"); + } + else + { + _logger.LogError("Content indexing failed"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during content indexing"); + } + }, cancellationToken); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping search indexing service"); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/EssentialCSharp.Web/Services/TypesenseOptions.cs b/EssentialCSharp.Web/Services/TypesenseOptions.cs new file mode 100644 index 00000000..63a3cebc --- /dev/null +++ b/EssentialCSharp.Web/Services/TypesenseOptions.cs @@ -0,0 +1,12 @@ +namespace EssentialCSharp.Web.Services; + +public class TypesenseOptions +{ + public const string SectionName = "TypesenseOptions"; + + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 8108; + public string Protocol { get; set; } = "http"; + public string ApiKey { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 30; +} \ No newline at end of file diff --git a/EssentialCSharp.Web/Services/TypesenseSearchService.cs b/EssentialCSharp.Web/Services/TypesenseSearchService.cs new file mode 100644 index 00000000..c06b9435 --- /dev/null +++ b/EssentialCSharp.Web/Services/TypesenseSearchService.cs @@ -0,0 +1,249 @@ +using EssentialCSharp.Web.Models; +using Microsoft.Extensions.Options; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EssentialCSharp.Web.Services; + +public class TypesenseSearchService : ITypesenseSearchService +{ + private readonly HttpClient _httpClient; + private readonly TypesenseOptions _options; + private readonly ILogger _logger; + private readonly string _baseUrl; + private const string CollectionName = "essentialcsharp_content"; + + public TypesenseSearchService(IOptions options, ILogger logger, HttpClient httpClient) + { + _options = options.Value; + _logger = logger; + _httpClient = httpClient; + _baseUrl = $"{_options.Protocol}://{_options.Host}:{_options.Port}"; + + _httpClient.DefaultRequestHeaders.Add("X-TYPESENSE-API-KEY", _options.ApiKey); + } + + public async Task InitializeCollectionAsync(CancellationToken cancellationToken = default) + { + try + { + // Check if collection already exists + var checkResponse = await _httpClient.GetAsync($"{_baseUrl}/collections/{CollectionName}", cancellationToken); + if (checkResponse.IsSuccessStatusCode) + { + _logger.LogInformation("Collection {CollectionName} already exists", CollectionName); + return true; + } + + if (checkResponse.StatusCode != System.Net.HttpStatusCode.NotFound) + { + _logger.LogError("Failed to check collection existence: {StatusCode}", checkResponse.StatusCode); + return false; + } + + // Collection doesn't exist, create it + _logger.LogInformation("Creating collection {CollectionName}", CollectionName); + + var schema = new + { + name = CollectionName, + fields = new[] + { + new { name = "id", type = "string", facet = false }, + new { name = "title", type = "string", facet = false }, + new { name = "content", type = "string", facet = false }, + new { name = "url", type = "string", facet = false }, + new { name = "chapter", type = "string", facet = true }, + new { name = "section", type = "string", facet = true }, + new { name = "tags", type = "string[]", facet = true }, + new { name = "created_at", type = "int64", facet = false } + }, + default_sorting_field = "created_at" + }; + + var json = JsonSerializer.Serialize(schema); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var createResponse = await _httpClient.PostAsync($"{_baseUrl}/collections", content, cancellationToken); + if (createResponse.IsSuccessStatusCode) + { + _logger.LogInformation("Successfully created collection {CollectionName}", CollectionName); + return true; + } + + var errorContent = await createResponse.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Failed to create collection {CollectionName}: {StatusCode} - {Error}", + CollectionName, createResponse.StatusCode, errorContent); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize collection {CollectionName}", CollectionName); + return false; + } + } + + public async Task SearchAsync(string query, int page = 1, int perPage = 10, CancellationToken cancellationToken = default) + { + try + { + var searchParams = new Dictionary + { + ["q"] = query, + ["query_by"] = "title,content,section,chapter", + ["page"] = page.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["per_page"] = perPage.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["highlight_full_fields"] = "title,content", + ["snippet_threshold"] = "30", + ["num_typos"] = "2", + ["drop_tokens_threshold"] = "1", + ["sort_by"] = "_text_match:desc,created_at:desc" + }; + + var queryString = string.Join("&", searchParams.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + var searchUrl = $"{_baseUrl}/collections/{CollectionName}/documents/search?{queryString}"; + + var response = await _httpClient.GetAsync(searchUrl, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Search failed: {StatusCode} - {Error}", response.StatusCode, errorContent); + return new SearchResult { Query = query, Page = page, PerPage = perPage }; + } + + var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken); + var searchResult = JsonSerializer.Deserialize(jsonContent); + + return new SearchResult + { + Results = searchResult?.hits?.Select(hit => hit.document).ToList() ?? [], + TotalCount = searchResult?.out_of ?? 0, + Page = page, + PerPage = perPage, + SearchTimeMs = searchResult?.search_time_ms ?? 0, + Query = query + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to search with query: {Query}", query); + return new SearchResult { Query = query, Page = page, PerPage = perPage }; + } + } + + public async Task IndexDocumentAsync(SearchDocument document, CancellationToken cancellationToken = default) + { + try + { + var json = JsonSerializer.Serialize(document); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"{_baseUrl}/collections/{CollectionName}/documents", content, cancellationToken); + if (response.IsSuccessStatusCode) + { + _logger.LogDebug("Successfully indexed document {DocumentId}", document.Id); + return true; + } + + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Failed to index document {DocumentId}: {StatusCode} - {Error}", + document.Id, response.StatusCode, errorContent); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to index document {DocumentId}", document.Id); + return false; + } + } + + public async Task IndexDocumentsAsync(IEnumerable documents, CancellationToken cancellationToken = default) + { + try + { + var documentsList = documents.ToList(); + if (documentsList.Count == 0) + { + return true; + } + + var json = JsonSerializer.Serialize(documentsList); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"{_baseUrl}/collections/{CollectionName}/documents/import", content, cancellationToken); + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Successfully indexed {Count} documents", documentsList.Count); + return true; + } + + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Failed to index documents: {StatusCode} - {Error}", response.StatusCode, errorContent); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to index documents"); + return false; + } + } + + public async Task DeleteDocumentAsync(string id, CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.DeleteAsync($"{_baseUrl}/collections/{CollectionName}/documents/{Uri.EscapeDataString(id)}", cancellationToken); + if (response.IsSuccessStatusCode) + { + _logger.LogDebug("Successfully deleted document {DocumentId}", id); + return true; + } + + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Failed to delete document {DocumentId}: {StatusCode} - {Error}", + id, response.StatusCode, errorContent); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete document {DocumentId}", id); + return false; + } + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync($"{_baseUrl}/health", cancellationToken); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Health check failed"); + return false; + } + } +} + +// Helper classes for Typesense API responses +public class TypesenseSearchResponse +{ +#pragma warning disable CA1707 // Identifiers should not contain underscores - matches Typesense API format + [JsonPropertyName("hits")] + public TypesenseHit[]? hits { get; set; } + + [JsonPropertyName("out_of")] + public int out_of { get; set; } + + [JsonPropertyName("search_time_ms")] + public double search_time_ms { get; set; } +#pragma warning restore CA1707 // Identifiers should not contain underscores +} + +public class TypesenseHit +{ + [JsonPropertyName("document")] + public SearchDocument document { get; set; } = new(); +} \ No newline at end of file diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 3c405fc3..37a8f180 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -57,7 +57,7 @@ - + @*Font Family*@ @@ -529,10 +529,8 @@ - - - + @await RenderSectionAsync("Scripts", required: false)