From 64e54d833d358aa4efb0dfe64f4a486c7acf3dea Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sat, 28 Mar 2026 18:57:48 +0200 Subject: [PATCH 01/39] feat: Initial commit for Ivy Ask Statistics project, including core functionality for automated quality testing of the IVY Ask API. Added project structure, models, services, and a user interface for running tests and displaying results. --- .../ivy-ask-statistics/Apps/RunApp.cs | 166 ++++++++++++++++++ .../ivy-ask-statistics/GlobalUsings.cs | 4 + .../IvyAskStatistics.csproj | 25 +++ project-demos/ivy-ask-statistics/Models.cs | 25 +++ project-demos/ivy-ask-statistics/Program.cs | 17 ++ project-demos/ivy-ask-statistics/Questions.cs | 76 ++++++++ project-demos/ivy-ask-statistics/README.md | 125 +++++++++++++ .../Services/IvyAskService.cs | 49 ++++++ 8 files changed, 487 insertions(+) create mode 100644 project-demos/ivy-ask-statistics/Apps/RunApp.cs create mode 100644 project-demos/ivy-ask-statistics/GlobalUsings.cs create mode 100644 project-demos/ivy-ask-statistics/IvyAskStatistics.csproj create mode 100644 project-demos/ivy-ask-statistics/Models.cs create mode 100644 project-demos/ivy-ask-statistics/Program.cs create mode 100644 project-demos/ivy-ask-statistics/Questions.cs create mode 100644 project-demos/ivy-ask-statistics/README.md create mode 100644 project-demos/ivy-ask-statistics/Services/IvyAskService.cs diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs new file mode 100644 index 00000000..6e37a3ff --- /dev/null +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -0,0 +1,166 @@ +namespace IvyAskStatistics.Apps; + +[App(icon: Icons.ChartBar, title: "IVY Ask Statistics")] +public class RunApp : ViewBase +{ + private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; + private static readonly string[] EnvOptions = ["production", "staging"]; + + // Implements IBuilder so TableBuilder can render custom cells + private class CellBuilder(Func build) : IBuilder + { + public object? Build(object? value, QuestionRow record) => build(record, value); + } + + public override object? Build() + { + var client = UseService(); + + // ── State ───────────────────────────────────────────────────────────── + var env = UseState("production"); + var difficultyFilter = UseState("all"); + + // runningIndex drives the step-by-step runner: + // -1 = idle + // 0..n-1 = currently processing question at that index + // ≥ count = finished (effect resets to -1) + var runningIndex = UseState(-1); + var completed = UseState>([]); + + // ── Hooks (must be at top, before derived values) ───────────────────── + // Reset when filters change + UseEffect(() => + { + completed.Set([]); + runningIndex.Set(-1); + }, [env.ToTrigger(), difficultyFilter.ToTrigger()]); + + // Step runner: each index change processes one question then increments + UseEffect(async () => + { + var idx = runningIndex.Value; + if (idx < 0) return; + + var questions = GetFiltered(difficultyFilter.Value); + + if (idx >= questions.Count) + { + runningIndex.Set(-1); + var s = completed.Value.Count(r => r.Status == "success"); + client.Toast($"Done! {s}/{questions.Count} answered"); + return; + } + + var baseUrl = env.Value == "staging" + ? "https://mcp-staging.ivy.app" + : "https://mcp.ivy.app"; + + var result = await IvyAskService.AskAsync(questions[idx], baseUrl); + completed.Set([.. completed.Value, result]); + runningIndex.Set(idx + 1); + }, [runningIndex.ToTrigger()]); + + // ── Derived values ──────────────────────────────────────────────────── + var questions = GetFiltered(difficultyFilter.Value); + var isRunning = runningIndex.Value >= 0; + + var done = completed.Value.Count; + var success = completed.Value.Count(r => r.Status == "success"); + var noAnswer = completed.Value.Count(r => r.Status == "no_answer"); + var errors = completed.Value.Count(r => r.Status == "error"); + var avgMs = done > 0 ? (int)completed.Value.Average(r => r.ResponseTimeMs) : 0; + var progressPct = questions.Count > 0 ? done * 100 / questions.Count : 0; + + // ── Build display rows ──────────────────────────────────────────────── + var rows = questions.Select((q, i) => + { + var r = completed.Value.FirstOrDefault(x => x.Question.Id == q.Id); + var status = r?.Status ?? (i == runningIndex.Value ? "running" : "pending"); + return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, status, r?.ResponseTimeMs); + }).ToList(); + + // ── Controls bar ────────────────────────────────────────────────────── + var controls = new Card( + Layout.Horizontal() + | env.ToSelectInput(EnvOptions).Disabled(isRunning) + | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(isRunning) + | new Spacer() + | new Button(isRunning ? "Running…" : "Run All", + onClick: _ => + { + completed.Set([]); + runningIndex.Set(0); + }) + .Primary() + .Icon(isRunning ? Icons.Loader : Icons.Play) + .Disabled(isRunning) + ); + + // ── Progress bar ────────────────────────────────────────────────────── + object? progressSection = isRunning || done > 0 + ? new Progress(progressPct).Goal($"{done} / {questions.Count} questions") + : null; + + // ── Summary card (only after run completes) ─────────────────────────── + object? summarySection = done > 0 && !isRunning + ? BuildSummary(success, noAnswer, errors, done, avgMs) + : null; + + // ── Questions table ─────────────────────────────────────────────────── + var table = new TableBuilder(rows) + .Builder(x => x.Difficulty, _ => new CellBuilder((row, _) => + new Badge(row.Difficulty).Variant(row.Difficulty switch + { + "easy" => BadgeVariant.Success, + "medium" => BadgeVariant.Info, + "hard" => BadgeVariant.Destructive, + _ => BadgeVariant.Secondary + }))) + .Builder(x => x.Status, _ => new CellBuilder((row, _) => + row.Status switch + { + "success" => (object)(Layout.Horizontal() + | new Badge("answered").Variant(BadgeVariant.Success) + | Text.Muted($"{row.ResponseTimeMs}ms")), + "no_answer" => new Badge("no answer").Variant(BadgeVariant.Destructive), + "error" => new Badge("error").Variant(BadgeVariant.Warning), + "running" => new Badge("running…").Variant(BadgeVariant.Info), + _ => new Badge("pending").Variant(BadgeVariant.Secondary) + })) + .Remove(x => x.Id) + .Remove(x => x.ResponseTimeMs) + .Build(); + + return Layout.Vertical() + | controls + | progressSection + | summarySection + | table; + } + + private static object BuildSummary(int success, int noAnswer, int errors, int total, int avgMs) + { + var rate = total > 0 ? success * 100 / total : 0; + + return new Card( + Layout.Horizontal() + | new Details([ + new Detail("Success Rate", $"{rate}% ({success}/{total})", false), + ]) + | new Details([ + new Detail("No Answer", noAnswer.ToString(), false), + ]) + | new Details([ + new Detail("Errors", errors.ToString(), false), + ]) + | new Details([ + new Detail("Avg Time", $"{avgMs} ms", false), + ]) + ); + } + + private static List GetFiltered(string difficulty) => + difficulty == "all" + ? Questions.All + : Questions.All.Where(q => q.Difficulty == difficulty).ToList(); +} diff --git a/project-demos/ivy-ask-statistics/GlobalUsings.cs b/project-demos/ivy-ask-statistics/GlobalUsings.cs new file mode 100644 index 00000000..30a2a61f --- /dev/null +++ b/project-demos/ivy-ask-statistics/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Ivy; +global using IvyAskStatistics.Services; +global using System.Globalization; +global using System.Net; diff --git a/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj new file mode 100644 index 00000000..71d93340 --- /dev/null +++ b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + enable + enable + CS8618;CS8603;CS8602;CS8604;CS9113 + IvyAskStatistics + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/project-demos/ivy-ask-statistics/Models.cs b/project-demos/ivy-ask-statistics/Models.cs new file mode 100644 index 00000000..8b5202b4 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Models.cs @@ -0,0 +1,25 @@ +namespace IvyAskStatistics; + +public record TestQuestion( + string Id, + string Widget, + string Difficulty, + string Question +); + +public record QuestionRun( + TestQuestion Question, + string Status, // "success" | "no_answer" | "error" + int ResponseTimeMs, + int HttpStatus +); + +// Flat row used for the TableBuilder display +public record QuestionRow( + string Id, + string Widget, + string Difficulty, + string Question, + string Status, + int? ResponseTimeMs +); diff --git a/project-demos/ivy-ask-statistics/Program.cs b/project-demos/ivy-ask-statistics/Program.cs new file mode 100644 index 00000000..5dca1226 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Program.cs @@ -0,0 +1,17 @@ +using IvyAskStatistics.Apps; + +CultureInfo.DefaultThreadCurrentCulture = CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo("en-US"); + +var server = new Server(); +#if DEBUG +server.UseHotReload(); +#endif +server.AddAppsFromAssembly(); +server.AddConnectionsFromAssembly(); + +var appShellSettings = new AppShellSettings() + .DefaultApp() + .UseTabs(preventDuplicates: true); + +server.UseAppShell(appShellSettings); +await server.RunAsync(); diff --git a/project-demos/ivy-ask-statistics/Questions.cs b/project-demos/ivy-ask-statistics/Questions.cs new file mode 100644 index 00000000..a54e8f1d --- /dev/null +++ b/project-demos/ivy-ask-statistics/Questions.cs @@ -0,0 +1,76 @@ +namespace IvyAskStatistics; + +public static class Questions +{ + public static readonly List All = + [ + // Button + new("button-easy-1", "button", "easy", "how to create a Button with an onClick handler in Ivy?"), + new("button-medium-1", "button", "medium", "how to change a Button variant to Destructive and add an icon in Ivy?"), + new("button-hard-1", "button", "hard", "how to make a Button open an external URL in a new tab with an icon on the right in Ivy?"), + + // Card + new("card-easy-1", "card", "easy", "how to create a Card widget in Ivy?"), + new("card-medium-1", "card", "medium", "how to add a title, description and action buttons to a Card in Ivy?"), + new("card-hard-1", "card", "hard", "how to create a clickable Card that navigates to another page in Ivy?"), + + // DataTable + new("datatable-easy-1", "datatable", "easy", "how to display a list of objects in a DataTable in Ivy?"), + new("datatable-medium-1", "datatable", "medium", "how to add sortable columns and a search filter to a DataTable in Ivy?"), + new("datatable-hard-1", "datatable", "hard", "how to add custom row action buttons to each row in a DataTable in Ivy?"), + new("datatable-hard-2", "datatable", "hard", "how to implement server-side pagination in a DataTable in Ivy?"), + + // Layout + new("layout-easy-1", "layout", "easy", "how to create a horizontal layout with multiple items in Ivy?"), + new("layout-medium-1", "layout", "medium", "how to create a grid layout with multiple columns in Ivy?"), + new("layout-hard-1", "layout", "hard", "how to control gap, alignment and wrap behavior in a Layout in Ivy?"), + + // Text + new("text-easy-1", "text", "easy", "how to display a heading using Text.H1 in Ivy?"), + new("text-medium-1", "text", "medium", "how to display inline code and a code block using Text widgets in Ivy?"), + + // Input + new("input-easy-1", "input", "easy", "how to create a text input bound to state in Ivy?"), + new("input-medium-1", "input", "medium", "how to create a select dropdown input with a list of options in Ivy?"), + new("input-hard-1", "input", "hard", "how to validate a text input and display an inline error message in Ivy?"), + + // Badge + new("badge-easy-1", "badge", "easy", "how to display a Badge widget in Ivy?"), + new("badge-medium-1", "badge", "medium", "how to change Badge variant color based on a status value in Ivy?"), + + // Progress + new("progress-easy-1", "progress", "easy", "how to show a Progress bar widget in Ivy?"), + new("progress-medium-1", "progress", "medium", "how to update a Progress bar value dynamically from state in Ivy?"), + + // Sheet + new("sheet-medium-1", "sheet", "medium", "how to open a Sheet side panel overlay in Ivy?"), + new("sheet-hard-1", "sheet", "hard", "how to create a Sheet with a form inside and a submit action in Ivy?"), + + // Navigation + new("navigation-easy-1", "navigation", "easy", "how to navigate to another page in an Ivy app?"), + new("navigation-medium-1", "navigation", "medium", "how to pass query parameters when navigating between pages in Ivy?"), + + // Callout + new("callout-easy-1", "callout", "easy", "how to show a Callout notification widget in Ivy?"), + + // Toast + new("toast-easy-1", "toast", "easy", "how to display a toast notification in Ivy?"), + new("toast-medium-1", "toast", "medium", "how to show a success or error toast with a custom message in Ivy?"), + + // Tooltip + new("tooltip-easy-1", "tooltip", "easy", "how to add a Tooltip to a widget in Ivy?"), + + // State + new("state-easy-1", "state", "easy", "how to use UseState to manage state in an Ivy app?"), + new("state-medium-1", "state", "medium", "how to share state between multiple components in Ivy?"), + + // Services + new("services-easy-1", "services", "easy", "how to inject and use a service with UseService in Ivy?"), + + // DropDownMenu + new("dropdown-medium-1", "dropdown", "medium", "how to create a DropDownMenu with multiple action items in Ivy?"), + + // Expandable + new("expandable-medium-1", "expandable", "medium", "how to create an Expandable collapsible section in Ivy?"), + ]; +} diff --git a/project-demos/ivy-ask-statistics/README.md b/project-demos/ivy-ask-statistics/README.md new file mode 100644 index 00000000..09157fc6 --- /dev/null +++ b/project-demos/ivy-ask-statistics/README.md @@ -0,0 +1,125 @@ +# IVY Ask Statistics + +Automated quality testing for the IVY Ask API (`mcp.ivy.app`). + +Sends a curated set of questions to the API, measures response time, and reports which questions return answers and which don't. Designed to run before/after releases to catch regressions. + +## What it does + +- Calls `GET https://mcp.ivy.app/questions?question=` for every question in `questions/questions.json` +- Classifies each result: + - **success** — HTTP 200 with a markdown answer + - **no_answer** — HTTP 404, API returned `NO_ANSWER_FOUND` + - **error** — anything else (network issue, bad request, etc.) +- Measures response time per question (avg + P90) +- Saves a full JSON result file to `results/` +- Prints a summary to stdout +- Writes a markdown table to `$GITHUB_STEP_SUMMARY` when running in GitHub Actions + +## Quick start + +```bash +# Run all 35 questions against production +./run-tests.sh + +# Filter by difficulty +DIFFICULTY=hard ./run-tests.sh + +# Run against staging +ENV=staging BASE_URL=https://mcp-staging.ivy.app ./run-tests.sh +``` + +**Requirements:** `jq`, `curl`, `python3` (all standard on macOS and Ubuntu). + +## Questions dataset + +`questions/questions.json` — 35 questions across 14 widgets, 3 difficulty levels each. + +| Difficulty | Description | +|-----------|-------------| +| `easy` | Basic widget usage — should always return an answer | +| `medium` | Specific features and configuration | +| `hard` | Advanced patterns, edge cases, compositions | + +To add or edit questions, just edit `questions/questions.json`. Each entry: + +```json +{ + "id": "button-easy-1", + "widget": "button", + "difficulty": "easy", + "question": "how to create a Button with an onClick handler in Ivy?" +} +``` + +- `id` must be unique +- `widget` is used for grouping in the summary +- `difficulty` must be `easy`, `medium`, or `hard` +- `question` should be a single, simple question (no compound questions) + +## Results format + +Each run writes a timestamped JSON file to `results/` (gitignored): + +``` +results/results-production-20260328-160000.json +``` + +Structure: + +```json +{ + "meta": { + "timestamp": "2026-03-28T16:00:00Z", + "environment": "production", + "total": 35, + "success": 30, + "no_answer": 5, + "errors": 0, + "successRatePct": 86, + "avgResponseMs": 1200, + "p90ResponseMs": 2400, + "byDifficulty": [...], + "byWidget": [...] + }, + "results": [ + { + "id": "button-easy-1", + "widget": "button", + "difficulty": "easy", + "question": "how to create a Button with an onClick handler in Ivy?", + "status": "success", + "responseTimeMs": 980, + "httpStatus": "200", + "timestamp": "2026-03-28T16:00:00Z", + "environment": "production" + } + ] +} +``` + +## CI / GitHub Actions + +The workflow at `.github/workflows/ivy-ask-statistics.yml` supports: + +**Manual trigger** — go to Actions → IVY Ask Statistics → Run workflow: +- Choose environment: `production` or `staging` +- Optionally filter by difficulty + +**Scheduled** — runs every Monday at 08:00 UTC against production. + +Results are uploaded as a GitHub Actions artifact (90-day retention) and displayed as a table in the job summary. + +### Staging URL + +Set the `IVY_ASK_STAGING_URL` repository secret to override the default staging URL. + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ENV` | `production` | Label written to results | +| `BASE_URL` | `https://mcp.ivy.app` | API base URL | +| `DIFFICULTY` | _(all)_ | Filter: `easy`, `medium`, or `hard` | +| `QUESTIONS_FILE` | `questions/questions.json` | Path to questions dataset | +| `RESULTS_DIR` | `results/` | Where to write result files | diff --git a/project-demos/ivy-ask-statistics/Services/IvyAskService.cs b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs new file mode 100644 index 00000000..e5c74799 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using IvyAskStatistics; + +namespace IvyAskStatistics.Services; + +public static class IvyAskService +{ + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; + + /// + /// Sends a question to the IVY Ask API and returns the result with timing. + /// GET {baseUrl}/questions?question={encoded} + /// + /// Status codes: + /// 200 + body → "success" (answer found) + /// 404 → "no_answer" (NO_ANSWER_FOUND) + /// other → "error" + /// + public static async Task AskAsync(TestQuestion question, string baseUrl) + { + var encoded = Uri.EscapeDataString(question.Question); + var url = $"{baseUrl}/questions?question={encoded}"; + + var sw = Stopwatch.StartNew(); + try + { + var response = await _http.GetAsync(url); + sw.Stop(); + + var body = await response.Content.ReadAsStringAsync(); + var ms = (int)sw.ElapsedMilliseconds; + var httpStatus = (int)response.StatusCode; + + var status = response.StatusCode switch + { + HttpStatusCode.OK when !string.IsNullOrWhiteSpace(body) => "success", + HttpStatusCode.NotFound => "no_answer", + _ => "error" + }; + + return new QuestionRun(question, status, ms, httpStatus); + } + catch + { + sw.Stop(); + return new QuestionRun(question, "error", (int)sw.ElapsedMilliseconds, 0); + } + } +} From c769c3ee4bf34f883b02185296f43d642193b655 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sat, 28 Mar 2026 19:05:19 +0200 Subject: [PATCH 02/39] refactor: Simplify environment handling in RunApp by removing dynamic base URL selection and consolidating to a single constant URL --- project-demos/ivy-ask-statistics/Apps/RunApp.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index 6e37a3ff..b7e4784d 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -4,7 +4,7 @@ namespace IvyAskStatistics.Apps; public class RunApp : ViewBase { private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; - private static readonly string[] EnvOptions = ["production", "staging"]; + private const string BaseUrl = "https://mcp.ivy.app"; // Implements IBuilder so TableBuilder can render custom cells private class CellBuilder(Func build) : IBuilder @@ -17,7 +17,6 @@ private class CellBuilder(Func build) : IBuilder< var client = UseService(); // ── State ───────────────────────────────────────────────────────────── - var env = UseState("production"); var difficultyFilter = UseState("all"); // runningIndex drives the step-by-step runner: @@ -28,12 +27,12 @@ private class CellBuilder(Func build) : IBuilder< var completed = UseState>([]); // ── Hooks (must be at top, before derived values) ───────────────────── - // Reset when filters change + // Reset when difficulty filter changes UseEffect(() => { completed.Set([]); runningIndex.Set(-1); - }, [env.ToTrigger(), difficultyFilter.ToTrigger()]); + }, [difficultyFilter.ToTrigger()]); // Step runner: each index change processes one question then increments UseEffect(async () => @@ -51,11 +50,7 @@ private class CellBuilder(Func build) : IBuilder< return; } - var baseUrl = env.Value == "staging" - ? "https://mcp-staging.ivy.app" - : "https://mcp.ivy.app"; - - var result = await IvyAskService.AskAsync(questions[idx], baseUrl); + var result = await IvyAskService.AskAsync(questions[idx], BaseUrl); completed.Set([.. completed.Value, result]); runningIndex.Set(idx + 1); }, [runningIndex.ToTrigger()]); @@ -82,7 +77,6 @@ private class CellBuilder(Func build) : IBuilder< // ── Controls bar ────────────────────────────────────────────────────── var controls = new Card( Layout.Horizontal() - | env.ToSelectInput(EnvOptions).Disabled(isRunning) | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(isRunning) | new Spacer() | new Button(isRunning ? "Running…" : "Run All", From 07fd3d45f165e3d551bff34cbb08b20ffab68784 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sun, 29 Mar 2026 13:39:44 +0300 Subject: [PATCH 03/39] feat: Implement database connection and question generation features, including new models, services, and UI components for managing questions in the Ivy Ask Statistics project --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 153 ++++++++++++++++++ .../ivy-ask-statistics/Apps/RunApp.cs | 63 ++++---- .../Connections/AppConnection.cs | 58 +++++++ .../Connections/AppDbContext.cs | 8 + .../Connections/AppDbContextFactory.cs | 58 +++++++ .../Connections/QuestionEntity.cs | 26 +++ .../ivy-ask-statistics/GlobalUsings.cs | 5 + .../IvyAskStatistics.csproj | 8 + .../ivy-ask-statistics/Models/IvyWidget.cs | 3 + .../{Models.cs => Models/RunModels.cs} | 11 +- .../ivy-ask-statistics/Models/WidgetRow.cs | 4 + project-demos/ivy-ask-statistics/Questions.cs | 76 --------- .../Services/IvyAskService.cs | 42 ++++- .../Services/QuestionGeneratorService.cs | 109 +++++++++++++ 14 files changed, 510 insertions(+), 114 deletions(-) create mode 100644 project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs create mode 100644 project-demos/ivy-ask-statistics/Connections/AppConnection.cs create mode 100644 project-demos/ivy-ask-statistics/Connections/AppDbContext.cs create mode 100644 project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs create mode 100644 project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs create mode 100644 project-demos/ivy-ask-statistics/Models/IvyWidget.cs rename project-demos/ivy-ask-statistics/{Models.cs => Models/RunModels.cs} (63%) create mode 100644 project-demos/ivy-ask-statistics/Models/WidgetRow.cs delete mode 100644 project-demos/ivy-ask-statistics/Questions.cs create mode 100644 project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs new file mode 100644 index 00000000..b4368ffe --- /dev/null +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -0,0 +1,153 @@ +namespace IvyAskStatistics.Apps; + +[App(icon: Icons.Database, title: "Questions")] +public class QuestionsApp : ViewBase +{ + private class CellBuilder(Func build) : IBuilder + { + public object? Build(object? value, WidgetRow record) => build(record, value); + } + + public override object? Build() + { + var factory = UseService(); + var configuration = UseService(); + var client = UseService(); + + // ── State ───────────────────────────────────────────────────────────── + var openAiKey = UseState(configuration["OpenAI:ApiKey"] ?? ""); + var generateRequest = UseState(null); + var isGenerating = UseState(false); + var generatingStatus = UseState(""); + var refreshTick = UseState(0); + + // ── Hooks ───────────────────────────────────────────────────────────── + // Async generate flow triggered by state change + UseEffect(async () => + { + var widget = generateRequest.Value; + if (widget == null) return; + + isGenerating.Set(true); + try + { + await QuestionGeneratorService.GenerateAndSaveAsync( + widget, + openAiKey.Value, + factory, + new Progress(msg => generatingStatus.Set(msg))); + + refreshTick.Set(refreshTick.Value + 1); + client.Toast($"Generated 30 questions for {widget.Name}"); + } + catch (Exception ex) + { + client.Toast($"Error generating questions for {widget.Name}: {ex.Message}"); + } + finally + { + isGenerating.Set(false); + generatingStatus.Set(""); + generateRequest.Set(null); + } + }, [generateRequest.ToTrigger()]); + + // ── Queries ─────────────────────────────────────────────────────────── + var widgetsQuery = UseQuery, string>( + key: "ivy-widgets", + fetcher: async (_, ct) => await IvyAskService.GetWidgetsAsync()); + + var countsQuery = UseQuery, int>( + key: refreshTick.Value, + fetcher: async (_, ct) => + { + await using var ctx = factory.CreateDbContext(); + var grouped = await ctx.Questions + .GroupBy(q => new { q.Widget, q.Difficulty }) + .Select(g => new { g.Key.Widget, g.Key.Difficulty, Count = g.Count() }) + .ToListAsync(ct); + + return grouped + .GroupBy(x => x.Widget) + .ToDictionary( + g => g.Key, + g => ( + easy: g.FirstOrDefault(x => x.Difficulty == "easy")?.Count ?? 0, + medium: g.FirstOrDefault(x => x.Difficulty == "medium")?.Count ?? 0, + hard: g.FirstOrDefault(x => x.Difficulty == "hard")?.Count ?? 0 + )); + }); + + // ── Build rows ──────────────────────────────────────────────────────── + var widgets = widgetsQuery.Value ?? []; + var counts = countsQuery.Value ?? new Dictionary(); + + var rows = widgets.Select(w => + { + var (easy, medium, hard) = counts.GetValueOrDefault(w.Name); + return new WidgetRow(w.Name, w.Category, easy, medium, hard); + }).ToList(); // Actions defaults to "" + + // ── OpenAI key + seed bar ───────────────────────────────────────────── + var hasKey = !string.IsNullOrWhiteSpace(openAiKey.Value); + var totalQuestions = rows.Sum(r => r.Easy + r.Medium + r.Hard); + + var headerCard = new Card( + Layout.Vertical().Gap(3) + | (Layout.Horizontal().Gap(3) + | (Layout.Vertical().Gap(1) + | Text.Block("OpenAI API Key").Bold().Small() + | openAiKey.ToPasswordInput().Placeholder("sk-…").Width(Size.Units(80))) + | new Spacer() + | Text.Block($"{totalQuestions} total questions in DB").Muted().Small()) + | (isGenerating.Value + ? (object)(Layout.Horizontal().Gap(2) + | new Icon(Icons.Loader).Small() + | Text.Muted(generatingStatus.Value)) + : null) + ); + + // ── Widgets table ───────────────────────────────────────────────────── + if (widgetsQuery.Loading) + { + return Layout.Vertical() + | headerCard + | new Progress(-1).Goal("Loading widgets…"); + } + + var table = new TableBuilder(rows) + .Builder(x => x.Easy, _ => new CellBuilder((row, _) => + row.Easy > 0 + ? (object)new Badge(row.Easy.ToString()).Variant(BadgeVariant.Success) + : Text.Muted("–"))) + .Builder(x => x.Medium, _ => new CellBuilder((row, _) => + row.Medium > 0 + ? (object)new Badge(row.Medium.ToString()).Variant(BadgeVariant.Info) + : Text.Muted("–"))) + .Builder(x => x.Hard, _ => new CellBuilder((row, _) => + row.Hard > 0 + ? (object)new Badge(row.Hard.ToString()).Variant(BadgeVariant.Destructive) + : Text.Muted("–"))) + .Builder(x => x.Actions, _ => new CellBuilder((row, _) => + new Button("Generate", onClick: _ => + { + if (!hasKey) + { + client.Toast("Enter your OpenAI API key first"); + return; + } + var widget = widgets.FirstOrDefault(w => w.Name == row.Widget); + if (widget != null) generateRequest.Set(widget); + }) + .Small() + .Variant(ButtonVariant.Outline) + .Disabled(isGenerating.Value) + .Icon(Icons.Sparkles))) + .Build(); + + return Layout.Vertical() + | headerCard + | table; + } + +} diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index b7e4784d..13e5fe4f 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -6,7 +6,6 @@ public class RunApp : ViewBase private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; private const string BaseUrl = "https://mcp.ivy.app"; - // Implements IBuilder so TableBuilder can render custom cells private class CellBuilder(Func build) : IBuilder { public object? Build(object? value, QuestionRow record) => build(record, value); @@ -14,33 +13,27 @@ private class CellBuilder(Func build) : IBuilder< public override object? Build() { + var factory = UseService(); var client = UseService(); // ── State ───────────────────────────────────────────────────────────── var difficultyFilter = UseState("all"); - - // runningIndex drives the step-by-step runner: - // -1 = idle - // 0..n-1 = currently processing question at that index - // ≥ count = finished (effect resets to -1) var runningIndex = UseState(-1); var completed = UseState>([]); // ── Hooks (must be at top, before derived values) ───────────────────── - // Reset when difficulty filter changes UseEffect(() => { completed.Set([]); runningIndex.Set(-1); }, [difficultyFilter.ToTrigger()]); - // Step runner: each index change processes one question then increments UseEffect(async () => { var idx = runningIndex.Value; if (idx < 0) return; - var questions = GetFiltered(difficultyFilter.Value); + var questions = await LoadQuestionsAsync(factory, difficultyFilter.Value); if (idx >= questions.Count) { @@ -55,10 +48,15 @@ private class CellBuilder(Func build) : IBuilder< runningIndex.Set(idx + 1); }, [runningIndex.ToTrigger()]); - // ── Derived values ──────────────────────────────────────────────────── - var questions = GetFiltered(difficultyFilter.Value); + // ── Queries ─────────────────────────────────────────────────────────── + var questionsQuery = UseQuery, string>( + key: $"questions-{difficultyFilter.Value}", + fetcher: async (_, ct) => await LoadQuestionsAsync(factory, difficultyFilter.Value)); + + var questions = questionsQuery.Value ?? []; var isRunning = runningIndex.Value >= 0; + // ── Derived values ──────────────────────────────────────────────────── var done = completed.Value.Count; var success = completed.Value.Count(r => r.Status == "success"); var noAnswer = completed.Value.Count(r => r.Status == "no_answer"); @@ -78,6 +76,7 @@ private class CellBuilder(Func build) : IBuilder< var controls = new Card( Layout.Horizontal() | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(isRunning) + | Text.Muted($"{questions.Count} questions") | new Spacer() | new Button(isRunning ? "Running…" : "Run All", onClick: _ => @@ -87,7 +86,7 @@ private class CellBuilder(Func build) : IBuilder< }) .Primary() .Icon(isRunning ? Icons.Loader : Icons.Play) - .Disabled(isRunning) + .Disabled(isRunning || questionsQuery.Loading || questions.Count == 0) ); // ── Progress bar ────────────────────────────────────────────────────── @@ -132,29 +131,35 @@ private class CellBuilder(Func build) : IBuilder< | table; } + private static async Task> LoadQuestionsAsync( + AppDbContextFactory factory, + string difficulty) + { + await using var ctx = factory.CreateDbContext(); + var query = ctx.Questions.AsQueryable(); + if (difficulty != "all") + query = query.Where(q => q.Difficulty == difficulty); + + var entities = await query + .OrderBy(q => q.Widget) + .ThenBy(q => q.Difficulty) + .ToListAsync(); + + return entities + .Select(e => new TestQuestion(e.Id.ToString(), e.Widget, e.Difficulty, e.QuestionText)) + .ToList(); + } + private static object BuildSummary(int success, int noAnswer, int errors, int total, int avgMs) { var rate = total > 0 ? success * 100 / total : 0; return new Card( Layout.Horizontal() - | new Details([ - new Detail("Success Rate", $"{rate}% ({success}/{total})", false), - ]) - | new Details([ - new Detail("No Answer", noAnswer.ToString(), false), - ]) - | new Details([ - new Detail("Errors", errors.ToString(), false), - ]) - | new Details([ - new Detail("Avg Time", $"{avgMs} ms", false), - ]) + | new Details([new Detail("Success Rate", $"{rate}% ({success}/{total})", false)]) + | new Details([new Detail("No Answer", noAnswer.ToString(), false)]) + | new Details([new Detail("Errors", errors.ToString(), false)]) + | new Details([new Detail("Avg Time", $"{avgMs} ms", false)]) ); } - - private static List GetFiltered(string difficulty) => - difficulty == "all" - ? Questions.All - : Questions.All.Where(q => q.Difficulty == difficulty).ToList(); } diff --git a/project-demos/ivy-ask-statistics/Connections/AppConnection.cs b/project-demos/ivy-ask-statistics/Connections/AppConnection.cs new file mode 100644 index 00000000..dedaede5 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Connections/AppConnection.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace IvyAskStatistics.Connections; + +public class AppConnection : IConnection +{ + public string GetName() => "IvyAskDb"; + + public string GetNamespace() => typeof(AppConnection).Namespace!; + + public string GetConnectionType() => "EntityFramework.PostgreSQL"; + + public string GetContext(string connectionPath) => ""; + + public ConnectionEntity[] GetEntities() + { + return typeof(AppDbContext) + .GetProperties() + .Where(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + .Select(p => new ConnectionEntity( + p.PropertyType.GenericTypeArguments[0].Name, + p.Name)) + .ToArray(); + } + + public Task<(bool ok, string? message)> TestConnection(IConfiguration configuration) + { + try + { + var cs = configuration["DB_CONNECTION_STRING"]; + if (string.IsNullOrWhiteSpace(cs)) + return Task.FromResult<(bool, string?)>((false, "DB_CONNECTION_STRING not set in user-secrets")); + + var options = new DbContextOptionsBuilder().UseNpgsql(cs).Options; + using var ctx = new AppDbContext(options); + var canConnect = ctx.Database.CanConnect(); + return Task.FromResult<(bool, string?)>(canConnect + ? (true, null) + : (false, "Cannot connect to database")); + } + catch (Exception ex) + { + return Task.FromResult<(bool, string?)>((false, ex.Message)); + } + } + + public void RegisterServices(Server server) + { + server.Services.AddSingleton(); + } + + public Secret[] GetSecrets() + { + return [new Secret("DB_CONNECTION_STRING", "PostgreSQL connection string (Supabase)")]; + } +} diff --git a/project-demos/ivy-ask-statistics/Connections/AppDbContext.cs b/project-demos/ivy-ask-statistics/Connections/AppDbContext.cs new file mode 100644 index 00000000..dc1df377 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Connections/AppDbContext.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore; + +namespace IvyAskStatistics.Connections; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Questions { get; set; } +} diff --git a/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs new file mode 100644 index 00000000..2d38713d --- /dev/null +++ b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace IvyAskStatistics.Connections; + +public sealed class AppDbContextFactory : IDbContextFactory +{ + private readonly IConfiguration _config; + private static bool _initialized; + private static readonly SemaphoreSlim _initLock = new(1, 1); + + public AppDbContextFactory(IConfiguration config) + { + _config = config; + EnsureInitialized(); + } + + public AppDbContext CreateDbContext() + { + var cs = _config["DB_CONNECTION_STRING"] + ?? throw new InvalidOperationException( + "DB_CONNECTION_STRING not set. Run: dotnet user-secrets set \"DB_CONNECTION_STRING\" \"\""); + + return new AppDbContext( + new DbContextOptionsBuilder() + .UseNpgsql(cs) + .Options); + } + + private void EnsureInitialized() + { + if (_initialized) return; + _initLock.Wait(); + try + { + if (_initialized) return; + using var ctx = CreateDbContext(); + // EnsureCreated() does nothing when the database already exists (e.g. Supabase). + // Use explicit CREATE TABLE IF NOT EXISTS instead. + ctx.Database.ExecuteSqlRaw(""" + CREATE TABLE IF NOT EXISTS ivy_ask_questions ( + "Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "Widget" VARCHAR(100) NOT NULL, + "Category" VARCHAR(100) NOT NULL DEFAULT '', + "Difficulty" VARCHAR(10) NOT NULL, + "QuestionText" TEXT NOT NULL, + "Source" VARCHAR(20) NOT NULL DEFAULT 'manual', + "CreatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + """); + _initialized = true; + } + finally + { + _initLock.Release(); + } + } +} diff --git a/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs b/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs new file mode 100644 index 00000000..4f25a92e --- /dev/null +++ b/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace IvyAskStatistics.Connections; + +[Table("ivy_ask_questions")] +public class QuestionEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [MaxLength(100)] + public string Widget { get; set; } = ""; + + [MaxLength(100)] + public string Category { get; set; } = ""; + + [MaxLength(10)] + public string Difficulty { get; set; } = ""; + + public string QuestionText { get; set; } = ""; + + [MaxLength(20)] + public string Source { get; set; } = "manual"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/project-demos/ivy-ask-statistics/GlobalUsings.cs b/project-demos/ivy-ask-statistics/GlobalUsings.cs index 30a2a61f..b79e8b7c 100644 --- a/project-demos/ivy-ask-statistics/GlobalUsings.cs +++ b/project-demos/ivy-ask-statistics/GlobalUsings.cs @@ -1,4 +1,9 @@ global using Ivy; +global using IvyAskStatistics.Models; global using IvyAskStatistics.Services; +global using IvyAskStatistics.Connections; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; global using System.Globalization; global using System.Net; diff --git a/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj index 71d93340..6c636b2b 100644 --- a/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj +++ b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj @@ -7,6 +7,7 @@ enable CS8618;CS8603;CS8602;CS8604;CS9113 IvyAskStatistics + ivy-ask-statistics-a3f2c1d4 @@ -15,10 +16,17 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/project-demos/ivy-ask-statistics/Models/IvyWidget.cs b/project-demos/ivy-ask-statistics/Models/IvyWidget.cs new file mode 100644 index 00000000..142f6107 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Models/IvyWidget.cs @@ -0,0 +1,3 @@ +namespace IvyAskStatistics.Models; + +public record IvyWidget(string Name, string Category, string DocLink); diff --git a/project-demos/ivy-ask-statistics/Models.cs b/project-demos/ivy-ask-statistics/Models/RunModels.cs similarity index 63% rename from project-demos/ivy-ask-statistics/Models.cs rename to project-demos/ivy-ask-statistics/Models/RunModels.cs index 8b5202b4..020f335c 100644 --- a/project-demos/ivy-ask-statistics/Models.cs +++ b/project-demos/ivy-ask-statistics/Models/RunModels.cs @@ -1,11 +1,6 @@ -namespace IvyAskStatistics; +namespace IvyAskStatistics.Models; -public record TestQuestion( - string Id, - string Widget, - string Difficulty, - string Question -); +public record TestQuestion(string Id, string Widget, string Difficulty, string Question); public record QuestionRun( TestQuestion Question, @@ -14,7 +9,7 @@ public record QuestionRun( int HttpStatus ); -// Flat row used for the TableBuilder display +// Flat row for the TableBuilder in RunApp public record QuestionRow( string Id, string Widget, diff --git a/project-demos/ivy-ask-statistics/Models/WidgetRow.cs b/project-demos/ivy-ask-statistics/Models/WidgetRow.cs new file mode 100644 index 00000000..13cb223f --- /dev/null +++ b/project-demos/ivy-ask-statistics/Models/WidgetRow.cs @@ -0,0 +1,4 @@ +namespace IvyAskStatistics.Models; + +// Row for the widgets table in QuestionsApp (Actions is a placeholder for the Generate button) +public record WidgetRow(string Widget, string Category, int Easy, int Medium, int Hard, string Actions = ""); diff --git a/project-demos/ivy-ask-statistics/Questions.cs b/project-demos/ivy-ask-statistics/Questions.cs deleted file mode 100644 index a54e8f1d..00000000 --- a/project-demos/ivy-ask-statistics/Questions.cs +++ /dev/null @@ -1,76 +0,0 @@ -namespace IvyAskStatistics; - -public static class Questions -{ - public static readonly List All = - [ - // Button - new("button-easy-1", "button", "easy", "how to create a Button with an onClick handler in Ivy?"), - new("button-medium-1", "button", "medium", "how to change a Button variant to Destructive and add an icon in Ivy?"), - new("button-hard-1", "button", "hard", "how to make a Button open an external URL in a new tab with an icon on the right in Ivy?"), - - // Card - new("card-easy-1", "card", "easy", "how to create a Card widget in Ivy?"), - new("card-medium-1", "card", "medium", "how to add a title, description and action buttons to a Card in Ivy?"), - new("card-hard-1", "card", "hard", "how to create a clickable Card that navigates to another page in Ivy?"), - - // DataTable - new("datatable-easy-1", "datatable", "easy", "how to display a list of objects in a DataTable in Ivy?"), - new("datatable-medium-1", "datatable", "medium", "how to add sortable columns and a search filter to a DataTable in Ivy?"), - new("datatable-hard-1", "datatable", "hard", "how to add custom row action buttons to each row in a DataTable in Ivy?"), - new("datatable-hard-2", "datatable", "hard", "how to implement server-side pagination in a DataTable in Ivy?"), - - // Layout - new("layout-easy-1", "layout", "easy", "how to create a horizontal layout with multiple items in Ivy?"), - new("layout-medium-1", "layout", "medium", "how to create a grid layout with multiple columns in Ivy?"), - new("layout-hard-1", "layout", "hard", "how to control gap, alignment and wrap behavior in a Layout in Ivy?"), - - // Text - new("text-easy-1", "text", "easy", "how to display a heading using Text.H1 in Ivy?"), - new("text-medium-1", "text", "medium", "how to display inline code and a code block using Text widgets in Ivy?"), - - // Input - new("input-easy-1", "input", "easy", "how to create a text input bound to state in Ivy?"), - new("input-medium-1", "input", "medium", "how to create a select dropdown input with a list of options in Ivy?"), - new("input-hard-1", "input", "hard", "how to validate a text input and display an inline error message in Ivy?"), - - // Badge - new("badge-easy-1", "badge", "easy", "how to display a Badge widget in Ivy?"), - new("badge-medium-1", "badge", "medium", "how to change Badge variant color based on a status value in Ivy?"), - - // Progress - new("progress-easy-1", "progress", "easy", "how to show a Progress bar widget in Ivy?"), - new("progress-medium-1", "progress", "medium", "how to update a Progress bar value dynamically from state in Ivy?"), - - // Sheet - new("sheet-medium-1", "sheet", "medium", "how to open a Sheet side panel overlay in Ivy?"), - new("sheet-hard-1", "sheet", "hard", "how to create a Sheet with a form inside and a submit action in Ivy?"), - - // Navigation - new("navigation-easy-1", "navigation", "easy", "how to navigate to another page in an Ivy app?"), - new("navigation-medium-1", "navigation", "medium", "how to pass query parameters when navigating between pages in Ivy?"), - - // Callout - new("callout-easy-1", "callout", "easy", "how to show a Callout notification widget in Ivy?"), - - // Toast - new("toast-easy-1", "toast", "easy", "how to display a toast notification in Ivy?"), - new("toast-medium-1", "toast", "medium", "how to show a success or error toast with a custom message in Ivy?"), - - // Tooltip - new("tooltip-easy-1", "tooltip", "easy", "how to add a Tooltip to a widget in Ivy?"), - - // State - new("state-easy-1", "state", "easy", "how to use UseState to manage state in an Ivy app?"), - new("state-medium-1", "state", "medium", "how to share state between multiple components in Ivy?"), - - // Services - new("services-easy-1", "services", "easy", "how to inject and use a service with UseService in Ivy?"), - - // DropDownMenu - new("dropdown-medium-1", "dropdown", "medium", "how to create a DropDownMenu with multiple action items in Ivy?"), - - // Expandable - new("expandable-medium-1", "expandable", "medium", "how to create an Expandable collapsible section in Ivy?"), - ]; -} diff --git a/project-demos/ivy-ask-statistics/Services/IvyAskService.cs b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs index e5c74799..2e462151 100644 --- a/project-demos/ivy-ask-statistics/Services/IvyAskService.cs +++ b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs @@ -1,10 +1,10 @@ using System.Diagnostics; -using IvyAskStatistics; namespace IvyAskStatistics.Services; public static class IvyAskService { + private const string McpBase = "https://mcp.ivy.app"; private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; /// @@ -46,4 +46,44 @@ public static async Task AskAsync(TestQuestion question, string bas return new QuestionRun(question, "error", (int)sw.ElapsedMilliseconds, 0); } } + + /// + /// Fetches the full list of Ivy widgets from the docs API. + /// Returns widgets only (filters out non-widget topics). + /// + public static async Task> GetWidgetsAsync() + { + var yaml = await _http.GetStringAsync($"{McpBase}/docs"); + var widgets = new List(); + + var lines = yaml.Split('\n'); + for (var i = 0; i < lines.Length - 1; i++) + { + var nameLine = lines[i].Trim(); + if (!nameLine.StartsWith("- name: Ivy.Widgets.")) continue; + + var fullName = nameLine["- name: ".Length..]; + var linkLine = lines[i + 1].Trim(); + var link = linkLine.StartsWith("link: ") ? linkLine["link: ".Length..] : ""; + + // "Ivy.Widgets.Common.Button" → parts = ["Ivy", "Widgets", "Common", "Button"] + var parts = fullName.Split('.'); + if (parts.Length < 4) continue; + + var category = parts[2]; + var name = parts[3]; + + widgets.Add(new IvyWidget(name, category, link.Trim())); + } + + return widgets.OrderBy(w => w.Category).ThenBy(w => w.Name).ToList(); + } + + /// + /// Fetches the Markdown documentation for a specific widget. + /// + public static async Task GetWidgetDocsAsync(string docLink) + { + return await _http.GetStringAsync($"{McpBase}/{docLink}"); + } } diff --git a/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs new file mode 100644 index 00000000..cf0cc2f3 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs @@ -0,0 +1,109 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using IvyAskStatistics.Connections; +using Microsoft.EntityFrameworkCore; + +namespace IvyAskStatistics.Services; + +public static class QuestionGeneratorService +{ + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(60) }; + private const string OpenAiUrl = "https://api.openai.com/v1/chat/completions"; + + /// + /// Generates 10 questions per difficulty (easy / medium / hard) for a widget + /// and saves them to the database, replacing any previously generated ones. + /// + public static async Task GenerateAndSaveAsync( + IvyWidget widget, + string openAiKey, + AppDbContextFactory factory, + IProgress? progress = null) + { + var docs = await IvyAskService.GetWidgetDocsAsync(widget.DocLink); + + foreach (var difficulty in new[] { "easy", "medium", "hard" }) + { + progress?.Report($"Generating {difficulty} questions for {widget.Name}…"); + + var questions = await GenerateQuestionsAsync(widget.Name, docs, difficulty, openAiKey); + + await using var ctx = factory.CreateDbContext(); + + var existing = await ctx.Questions + .Where(q => q.Widget == widget.Name && q.Difficulty == difficulty && q.Source == "generated") + .ToListAsync(); + ctx.Questions.RemoveRange(existing); + + ctx.Questions.AddRange(questions.Select(q => new QuestionEntity + { + Widget = widget.Name, + Category = widget.Category, + Difficulty = difficulty, + QuestionText = q, + Source = "generated", + CreatedAt = DateTime.UtcNow + })); + + await ctx.SaveChangesAsync(); + } + } + + private static async Task> GenerateQuestionsAsync( + string widgetName, + string docs, + string difficulty, + string openAiKey) + { + var prompt = $$""" + You are an expert in the Ivy Framework for C#. + + Here is the documentation for the "{{widgetName}}" widget: + + {{docs}} + + Generate exactly 10 {{difficulty}} questions that a developer might ask about this widget. + + DIFFICULTY GUIDELINES: + - easy: Basic usage ("how to create", "how to show", simple properties) + - medium: Specific features, configuration, event handlers, styling + - hard: Advanced patterns, combining with other widgets, dynamic data, edge cases + + Rules: + 1. Each question must be specific and about the {{widgetName}} widget in Ivy + 2. No compound questions - one concept per question + 3. Keep questions concise (under 20 words) + 4. Return ONLY valid JSON in this exact format: {"questions": ["q1", "q2", "q3"]} + """; + + using var request = new HttpRequestMessage(HttpMethod.Post, OpenAiUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", openAiKey); + request.Content = JsonContent.Create(new + { + model = "gpt-4o-mini", + messages = new[] { new { role = "user", content = prompt } }, + temperature = 0.7, + response_format = new { type = "json_object" } + }); + + var response = await _http.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var root = await response.Content.ReadFromJsonAsync(); + var content = root + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString() ?? "{}"; + + var parsed = JsonSerializer.Deserialize(content); + return parsed + .GetProperty("questions") + .EnumerateArray() + .Select(q => q.GetString() ?? "") + .Where(q => !string.IsNullOrWhiteSpace(q)) + .Take(10) + .ToList(); + } +} From c3bf7f5bffa7cc1739347b685dc9a1dffd5aa5f4 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sun, 29 Mar 2026 13:49:03 +0300 Subject: [PATCH 04/39] refactor: Simplify QuestionsApp and RunApp by removing unused CellBuilder classes and enhancing DataTable configurations for better widget and question management --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 95 +++++++++---------- .../ivy-ask-statistics/Apps/RunApp.cs | 48 ++++------ .../ivy-ask-statistics/Models/WidgetRow.cs | 3 +- 3 files changed, 64 insertions(+), 82 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index b4368ffe..91e7b92c 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -3,11 +3,6 @@ namespace IvyAskStatistics.Apps; [App(icon: Icons.Database, title: "Questions")] public class QuestionsApp : ViewBase { - private class CellBuilder(Func build) : IBuilder - { - public object? Build(object? value, WidgetRow record) => build(record, value); - } - public override object? Build() { var factory = UseService(); @@ -22,7 +17,6 @@ private class CellBuilder(Func build) : IBuilder { var widget = generateRequest.Value; @@ -81,17 +75,16 @@ await QuestionGeneratorService.GenerateAndSaveAsync( // ── Build rows ──────────────────────────────────────────────────────── var widgets = widgetsQuery.Value ?? []; var counts = countsQuery.Value ?? new Dictionary(); + var hasKey = !string.IsNullOrWhiteSpace(openAiKey.Value); + var totalQuestions = counts.Values.Sum(c => c.easy + c.medium + c.hard); var rows = widgets.Select(w => { var (easy, medium, hard) = counts.GetValueOrDefault(w.Name); return new WidgetRow(w.Name, w.Category, easy, medium, hard); - }).ToList(); // Actions defaults to "" - - // ── OpenAI key + seed bar ───────────────────────────────────────────── - var hasKey = !string.IsNullOrWhiteSpace(openAiKey.Value); - var totalQuestions = rows.Sum(r => r.Easy + r.Medium + r.Hard); + }).ToList(); + // ── Header card ─────────────────────────────────────────────────────── var headerCard = new Card( Layout.Vertical().Gap(3) | (Layout.Horizontal().Gap(3) @@ -100,54 +93,54 @@ await QuestionGeneratorService.GenerateAndSaveAsync( | openAiKey.ToPasswordInput().Placeholder("sk-…").Width(Size.Units(80))) | new Spacer() | Text.Block($"{totalQuestions} total questions in DB").Muted().Small()) - | (isGenerating.Value + | (widgetsQuery.Loading ? (object)(Layout.Horizontal().Gap(2) | new Icon(Icons.Loader).Small() - | Text.Muted(generatingStatus.Value)) - : null) + | Text.Muted("Loading widgets…")) + : isGenerating.Value + ? (Layout.Horizontal().Gap(2) + | new Icon(Icons.Loader).Small() + | Text.Muted(generatingStatus.Value)) + : null) ); - // ── Widgets table ───────────────────────────────────────────────────── - if (widgetsQuery.Loading) - { - return Layout.Vertical() - | headerCard - | new Progress(-1).Goal("Loading widgets…"); - } - - var table = new TableBuilder(rows) - .Builder(x => x.Easy, _ => new CellBuilder((row, _) => - row.Easy > 0 - ? (object)new Badge(row.Easy.ToString()).Variant(BadgeVariant.Success) - : Text.Muted("–"))) - .Builder(x => x.Medium, _ => new CellBuilder((row, _) => - row.Medium > 0 - ? (object)new Badge(row.Medium.ToString()).Variant(BadgeVariant.Info) - : Text.Muted("–"))) - .Builder(x => x.Hard, _ => new CellBuilder((row, _) => - row.Hard > 0 - ? (object)new Badge(row.Hard.ToString()).Variant(BadgeVariant.Destructive) - : Text.Muted("–"))) - .Builder(x => x.Actions, _ => new CellBuilder((row, _) => - new Button("Generate", onClick: _ => + // ── Widgets DataTable ───────────────────────────────────────────────── + var table = rows.AsQueryable() + .ToDataTable(r => r.Widget) + .Header(r => r.Widget, "Widget") + .Header(r => r.Category, "Category") + .Header(r => r.Easy, "Easy") + .Header(r => r.Medium, "Medium") + .Header(r => r.Hard, "Hard") + .Width(r => r.Category, Size.Px(120)) + .Width(r => r.Easy, Size.Px(70)) + .Width(r => r.Medium, Size.Px(80)) + .Width(r => r.Hard, Size.Px(70)) + .RowActions( + MenuItem.Default(Icons.Sparkles, "generate").Label("Generate").Tag("generate")) + .OnRowAction(e => + { + var args = e.Value; + if (args?.Tag?.ToString() != "generate") return ValueTask.CompletedTask; + if (!hasKey) { - if (!hasKey) - { - client.Toast("Enter your OpenAI API key first"); - return; - } - var widget = widgets.FirstOrDefault(w => w.Name == row.Widget); - if (widget != null) generateRequest.Set(widget); - }) - .Small() - .Variant(ButtonVariant.Outline) - .Disabled(isGenerating.Value) - .Icon(Icons.Sparkles))) - .Build(); + client.Toast("Enter your OpenAI API key first"); + return ValueTask.CompletedTask; + } + var widget = widgets.FirstOrDefault(w => w.Name == args.Id?.ToString()); + if (widget != null) generateRequest.Set(widget); + return ValueTask.CompletedTask; + }) + .Config(config => + { + config.AllowSorting = true; + config.AllowFiltering = true; + config.ShowSearch = true; + config.ShowIndexColumn = false; + }); return Layout.Vertical() | headerCard | table; } - } diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index 13e5fe4f..c1fc3c7e 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -6,11 +6,6 @@ public class RunApp : ViewBase private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; private const string BaseUrl = "https://mcp.ivy.app"; - private class CellBuilder(Func build) : IBuilder - { - public object? Build(object? value, QuestionRow record) => build(record, value); - } - public override object? Build() { var factory = UseService(); @@ -99,30 +94,25 @@ private class CellBuilder(Func build) : IBuilder< ? BuildSummary(success, noAnswer, errors, done, avgMs) : null; - // ── Questions table ─────────────────────────────────────────────────── - var table = new TableBuilder(rows) - .Builder(x => x.Difficulty, _ => new CellBuilder((row, _) => - new Badge(row.Difficulty).Variant(row.Difficulty switch - { - "easy" => BadgeVariant.Success, - "medium" => BadgeVariant.Info, - "hard" => BadgeVariant.Destructive, - _ => BadgeVariant.Secondary - }))) - .Builder(x => x.Status, _ => new CellBuilder((row, _) => - row.Status switch - { - "success" => (object)(Layout.Horizontal() - | new Badge("answered").Variant(BadgeVariant.Success) - | Text.Muted($"{row.ResponseTimeMs}ms")), - "no_answer" => new Badge("no answer").Variant(BadgeVariant.Destructive), - "error" => new Badge("error").Variant(BadgeVariant.Warning), - "running" => new Badge("running…").Variant(BadgeVariant.Info), - _ => new Badge("pending").Variant(BadgeVariant.Secondary) - })) - .Remove(x => x.Id) - .Remove(x => x.ResponseTimeMs) - .Build(); + // ── Questions DataTable ─────────────────────────────────────────────── + var table = rows.AsQueryable() + .ToDataTable() + .Hidden(r => r.Id) + .Hidden(r => r.ResponseTimeMs) + .Header(r => r.Widget, "Widget") + .Header(r => r.Difficulty, "Difficulty") + .Header(r => r.Question, "Question") + .Header(r => r.Status, "Status") + .Width(r => r.Widget, Size.Px(120)) + .Width(r => r.Difficulty, Size.Px(100)) + .Width(r => r.Status, Size.Px(120)) + .Config(config => + { + config.AllowSorting = true; + config.AllowFiltering = true; + config.ShowSearch = true; + config.ShowIndexColumn = true; + }); return Layout.Vertical() | controls diff --git a/project-demos/ivy-ask-statistics/Models/WidgetRow.cs b/project-demos/ivy-ask-statistics/Models/WidgetRow.cs index 13cb223f..f5e075cd 100644 --- a/project-demos/ivy-ask-statistics/Models/WidgetRow.cs +++ b/project-demos/ivy-ask-statistics/Models/WidgetRow.cs @@ -1,4 +1,3 @@ namespace IvyAskStatistics.Models; -// Row for the widgets table in QuestionsApp (Actions is a placeholder for the Generate button) -public record WidgetRow(string Widget, string Category, int Easy, int Medium, int Hard, string Actions = ""); +public record WidgetRow(string Widget, string Category, int Easy, int Medium, int Hard); From 4278106869e846e7e3b0d463b82d8a8efb5baa14 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sun, 29 Mar 2026 18:01:25 +0300 Subject: [PATCH 05/39] feat: Enhance QuestionsApp with widget data management and question generation capabilities, including improved state handling and new service methods for fetching and parsing questions --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 255 ++++++++++++------ .../ivy-ask-statistics/Models/WidgetRow.cs | 9 +- .../Services/IvyAskService.cs | 117 +++++++- .../Services/QuestionGeneratorService.cs | 106 +++----- 4 files changed, 328 insertions(+), 159 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 91e7b92c..4d2ca895 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -1,146 +1,227 @@ namespace IvyAskStatistics.Apps; +file record WidgetTableData(List Rows, List Catalog, int QueryKey); + [App(icon: Icons.Database, title: "Questions")] public class QuestionsApp : ViewBase { public override object? Build() { - var factory = UseService(); + var factory = UseService(); var configuration = UseService(); - var client = UseService(); + var client = UseService(); + + var generateRequest = UseState(null); + var generatingWidget = UseState(""); + var generatingStatus = UseState(""); + var refreshTick = UseState(0); + var pendingRefreshFor = UseState(""); // widget whose table row is reloading after generation + var refreshToken = UseRefreshToken(); + var (alertView, showAlert) = UseAlert(); + + var tableQuery = UseQuery( + key: refreshTick.Value, + fetcher: async (queryKey, ct) => + { + await using var ctx = factory.CreateDbContext(); + + var grouped = await ctx.Questions + .AsNoTracking() + .GroupBy(q => new { q.Widget, q.Category, q.Difficulty }) + .Select(g => new + { + g.Key.Widget, + g.Key.Category, + g.Key.Difficulty, + Count = g.Count(), + MaxDate = g.Max(x => x.CreatedAt) + }) + .ToListAsync(ct); + + var countsByWidget = grouped + .GroupBy(x => x.Widget) + .ToDictionary( + g => g.Key, + g => ( + category: g.Select(x => x.Category).FirstOrDefault() ?? "", + easy: g.FirstOrDefault(x => x.Difficulty == "easy")?.Count ?? 0, + medium: g.FirstOrDefault(x => x.Difficulty == "medium")?.Count ?? 0, + hard: g.FirstOrDefault(x => x.Difficulty == "hard")?.Count ?? 0, + updatedAt: g.Max(x => x.MaxDate) + )); + + List catalog = []; + try { catalog = await IvyAskService.GetWidgetsAsync(); } + catch { /* offline */ } + + var byName = catalog.ToDictionary(w => w.Name); + foreach (var (widget, info) in countsByWidget) + if (!byName.ContainsKey(widget)) + byName[widget] = new IvyWidget(widget, info.category, ""); - // ── State ───────────────────────────────────────────────────────────── - var openAiKey = UseState(configuration["OpenAI:ApiKey"] ?? ""); - var generateRequest = UseState(null); - var isGenerating = UseState(false); - var generatingStatus = UseState(""); - var refreshTick = UseState(0); + var rows = byName.Values + .OrderBy(w => string.IsNullOrEmpty(w.Category) ? "zzz" : w.Category) + .ThenBy(w => w.Name) + .Select(w => + { + var c = countsByWidget.GetValueOrDefault(w.Name); + var category = string.IsNullOrEmpty(w.Category) ? "Unclassified" : w.Category; + var updated = c.updatedAt == default + ? "—" + : c.updatedAt.ToLocalTime().ToString("dd MMM yyyy, HH:mm"); + return new WidgetRow(w.Name, category, c.easy, c.medium, c.hard, updated, ""); + }) + .ToList(); + + return new WidgetTableData(rows, catalog, queryKey); + }, + options: new QueryOptions { KeepPrevious = true }); + + // Clear "Updating…" once the query result matches the current refreshTick (KeepPrevious-safe) + UseEffect(async () => + { + if (string.IsNullOrEmpty(pendingRefreshFor.Value)) return; + var targetKey = refreshTick.Value; + for (var guard = 0; guard < 600; guard++) + { + if (tableQuery.Value?.QueryKey == targetKey && !tableQuery.Loading) + break; + await Task.Delay(16); + } + if (!string.IsNullOrEmpty(pendingRefreshFor.Value)) + { + pendingRefreshFor.Set(""); + refreshToken.Refresh(); + } + }, [refreshTick.ToTrigger()]); + + UseEffect(() => { refreshToken.Refresh(); }, + [generatingWidget.ToTrigger(), generatingStatus.ToTrigger()]); - // ── Hooks ───────────────────────────────────────────────────────────── UseEffect(async () => { var widget = generateRequest.Value; if (widget == null) return; - isGenerating.Set(true); + generatingWidget.Set(widget.Name); try { + var baseUrl = configuration["IvyAsk:BaseUrl"] ?? IvyAskService.DefaultMcpBaseUrl; await QuestionGeneratorService.GenerateAndSaveAsync( - widget, - openAiKey.Value, - factory, + widget, factory, baseUrl, new Progress(msg => generatingStatus.Set(msg))); + pendingRefreshFor.Set(widget.Name); refreshTick.Set(refreshTick.Value + 1); client.Toast($"Generated 30 questions for {widget.Name}"); } catch (Exception ex) { - client.Toast($"Error generating questions for {widget.Name}: {ex.Message}"); + client.Toast($"Error: {ex.Message}"); + pendingRefreshFor.Set(""); } finally { - isGenerating.Set(false); + generatingWidget.Set(""); generatingStatus.Set(""); generateRequest.Set(null); } }, [generateRequest.ToTrigger()]); - // ── Queries ─────────────────────────────────────────────────────────── - var widgetsQuery = UseQuery, string>( - key: "ivy-widgets", - fetcher: async (_, ct) => await IvyAskService.GetWidgetsAsync()); + // ── Derived state ───────────────────────────────────────────────────── + var baseRows = tableQuery.Value?.Rows ?? []; + var catalog = tableQuery.Value?.Catalog ?? []; + var generating = generatingWidget.Value; + var isGenerating = !string.IsNullOrEmpty(generating); + // Only the initial mount uses the full-screen loader. After refreshTick bumps (post-generation + // refetch), never swap the whole view for Loading — avoids the table vanishing mid-flow. + var firstLoad = tableQuery.Loading && tableQuery.Value == null && refreshTick.Value == 0; + var pendingRow = pendingRefreshFor.Value; - var countsQuery = UseQuery, int>( - key: refreshTick.Value, - fetcher: async (_, ct) => - { - await using var ctx = factory.CreateDbContext(); - var grouped = await ctx.Questions - .GroupBy(q => new { q.Widget, q.Difficulty }) - .Select(g => new { g.Key.Widget, g.Key.Difficulty, Count = g.Count() }) - .ToListAsync(ct); + static string IdleStatus(WidgetRow r) + { + var n = r.Easy + r.Medium + r.Hard; + return n == 0 ? "○ Not generated" : "✓ Generated"; + } - return grouped - .GroupBy(x => x.Widget) - .ToDictionary( - g => g.Key, - g => ( - easy: g.FirstOrDefault(x => x.Difficulty == "easy")?.Count ?? 0, - medium: g.FirstOrDefault(x => x.Difficulty == "medium")?.Count ?? 0, - hard: g.FirstOrDefault(x => x.Difficulty == "hard")?.Count ?? 0 - )); - }); + // Status per row: generating / updating DB / idle + var rows = baseRows.Select(r => + { + if (r.Widget == generating) + { + return r with { Status = $"Generating…" }; + } - // ── Build rows ──────────────────────────────────────────────────────── - var widgets = widgetsQuery.Value ?? []; - var counts = countsQuery.Value ?? new Dictionary(); - var hasKey = !string.IsNullOrWhiteSpace(openAiKey.Value); - var totalQuestions = counts.Values.Sum(c => c.easy + c.medium + c.hard); + if (r.Widget == pendingRow) + return r with { Status = "Updating…" }; - var rows = widgets.Select(w => - { - var (easy, medium, hard) = counts.GetValueOrDefault(w.Name); - return new WidgetRow(w.Name, w.Category, easy, medium, hard); + return r with { Status = IdleStatus(r) }; }).ToList(); - // ── Header card ─────────────────────────────────────────────────────── - var headerCard = new Card( - Layout.Vertical().Gap(3) - | (Layout.Horizontal().Gap(3) - | (Layout.Vertical().Gap(1) - | Text.Block("OpenAI API Key").Bold().Small() - | openAiKey.ToPasswordInput().Placeholder("sk-…").Width(Size.Units(80))) - | new Spacer() - | Text.Block($"{totalQuestions} total questions in DB").Muted().Small()) - | (widgetsQuery.Loading - ? (object)(Layout.Horizontal().Gap(2) - | new Icon(Icons.Loader).Small() - | Text.Muted("Loading widgets…")) - : isGenerating.Value - ? (Layout.Horizontal().Gap(2) - | new Icon(Icons.Loader).Small() - | Text.Muted(generatingStatus.Value)) - : null) - ); - - // ── Widgets DataTable ───────────────────────────────────────────────── + if (firstLoad) + return Layout.Center() + | new Icon(Icons.Loader) + | Text.Muted("Loading…"); + var table = rows.AsQueryable() .ToDataTable(r => r.Widget) - .Header(r => r.Widget, "Widget") - .Header(r => r.Category, "Category") - .Header(r => r.Easy, "Easy") - .Header(r => r.Medium, "Medium") - .Header(r => r.Hard, "Hard") - .Width(r => r.Category, Size.Px(120)) - .Width(r => r.Easy, Size.Px(70)) - .Width(r => r.Medium, Size.Px(80)) - .Width(r => r.Hard, Size.Px(70)) + .RefreshToken(refreshToken) + // Stable key: do not tie to refreshTick — that remounted the whole table after each generation. + .Key("questions-widgets") + .Height(Size.Full()) + .Header(r => r.Widget, "Widget") + .Header(r => r.Category, "Category") + .Header(r => r.Easy, "Easy") + .Header(r => r.Medium, "Medium") + .Header(r => r.Hard, "Hard") + .Header(r => r.LastUpdated, "Last Generated") + .Header(r => r.Status, "Status") + .Width(r => r.Widget, Size.Px(160)) + .Width(r => r.Category, Size.Px(120)) + .Width(r => r.Easy, Size.Px(60)) + .Width(r => r.Medium, Size.Px(70)) + .Width(r => r.Hard, Size.Px(60)) + .Width(r => r.LastUpdated, Size.Px(170)) + .Width(r => r.Status, Size.Px(280)) .RowActions( - MenuItem.Default(Icons.Sparkles, "generate").Label("Generate").Tag("generate")) + MenuItem.Default(Icons.Sparkles, "generate").Label("Generate questions").Tag("generate")) .OnRowAction(e => { var args = e.Value; if (args?.Tag?.ToString() != "generate") return ValueTask.CompletedTask; - if (!hasKey) + if (isGenerating) { - client.Toast("Enter your OpenAI API key first"); + client.Toast("Already generating — please wait"); return ValueTask.CompletedTask; } - var widget = widgets.FirstOrDefault(w => w.Name == args.Id?.ToString()); - if (widget != null) generateRequest.Set(widget); + var name = args.Id?.ToString() ?? ""; + var widget = catalog.FirstOrDefault(w => w.Name == name) + ?? new IvyWidget(name, rows.FirstOrDefault(r => r.Widget == name)?.Category ?? "", ""); + showAlert( + $"Generate 30 questions for the \"{widget.Name}\" widget?\n\nIvy Ask will be called three times (easy / medium / hard). Any previously generated questions for this widget will be replaced.", + result => + { + if (!result.IsOk()) return; + // Same pattern as pr-staging-deploy: update UI immediately on confirm, then run async work. + generatingWidget.Set(widget.Name); + generatingStatus.Set("Starting…"); + generateRequest.Set(widget); + refreshToken.Refresh(); + }, + "Generate questions", + AlertButtonSet.OkCancel); return ValueTask.CompletedTask; }) .Config(config => { - config.AllowSorting = true; - config.AllowFiltering = true; - config.ShowSearch = true; + config.AllowSorting = true; + config.AllowFiltering = true; + config.ShowSearch = true; config.ShowIndexColumn = false; }); - return Layout.Vertical() - | headerCard + return Layout.Vertical().Height(Size.Full()) + | alertView | table; } } diff --git a/project-demos/ivy-ask-statistics/Models/WidgetRow.cs b/project-demos/ivy-ask-statistics/Models/WidgetRow.cs index f5e075cd..35a55c8f 100644 --- a/project-demos/ivy-ask-statistics/Models/WidgetRow.cs +++ b/project-demos/ivy-ask-statistics/Models/WidgetRow.cs @@ -1,3 +1,10 @@ namespace IvyAskStatistics.Models; -public record WidgetRow(string Widget, string Category, int Easy, int Medium, int Hard); +public record WidgetRow( + string Widget, + string Category, + int Easy, + int Medium, + int Hard, + string LastUpdated, + string Status = ""); diff --git a/project-demos/ivy-ask-statistics/Services/IvyAskService.cs b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs index 2e462151..41ed1b68 100644 --- a/project-demos/ivy-ask-statistics/Services/IvyAskService.cs +++ b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs @@ -1,11 +1,15 @@ using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using IvyAskStatistics.Connections; +using Microsoft.EntityFrameworkCore; namespace IvyAskStatistics.Services; public static class IvyAskService { - private const string McpBase = "https://mcp.ivy.app"; - private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; + public const string DefaultMcpBaseUrl = "https://mcp.ivy.app"; + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(120) }; /// /// Sends a question to the IVY Ask API and returns the result with timing. @@ -47,13 +51,78 @@ public static async Task AskAsync(TestQuestion question, string bas } } + /// + /// Calls Ivy Ask with a meta-prompt (e.g. “output JSON array of test questions”). + /// Same endpoint as normal Ask; the service returns generated text (often JSON). + /// + public static async Task FetchAnswerBodyAsync(string baseUrl, string prompt, CancellationToken ct = default) + { + var url = $"{baseUrl.TrimEnd('/')}/questions?question={Uri.EscapeDataString(prompt)}"; + using var response = await _http.GetAsync(url, ct); + if (response.StatusCode != HttpStatusCode.OK) return null; + return await response.Content.ReadAsStringAsync(ct); + } + + /// + /// Parses Ivy Ask response body into a list of question strings. + /// Handles raw JSON array, {"questions":[...]}, or fenced markdown code blocks. + /// + public static List ParseQuestionStringsFromBody(string body, int take = 10) + { + if (string.IsNullOrWhiteSpace(body)) return []; + + var trimmed = body.Trim(); + + // Strip ```json ... ``` if present + var fence = Regex.Match(trimmed, @"^```(?:json)?\s*([\s\S]*?)\s*```", RegexOptions.IgnoreCase); + if (fence.Success) + trimmed = fence.Groups[1].Value.Trim(); + + try + { + using var doc = JsonDocument.Parse(trimmed); + var root = doc.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + return TakeStrings(root, take); + + if (root.ValueKind == JsonValueKind.Object) + { + foreach (var name in new[] { "questions", "items", "data" }) + { + if (root.TryGetProperty(name, out var arr) && arr.ValueKind == JsonValueKind.Array) + return TakeStrings(arr, take); + } + } + } + catch (JsonException) + { + // One question per non-empty line + return trimmed + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(l => l.TrimStart('-', ' ', '\t', '"').TrimEnd('"', ',')) + .Where(l => l.Length > 5) + .Take(take) + .ToList(); + } + + return []; + } + + private static List TakeStrings(JsonElement array, int take) => + array.EnumerateArray() + .Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() ?? "" : e.GetRawText()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Take(take) + .ToList(); + /// /// Fetches the full list of Ivy widgets from the docs API. /// Returns widgets only (filters out non-widget topics). /// public static async Task> GetWidgetsAsync() { - var yaml = await _http.GetStringAsync($"{McpBase}/docs"); + var yaml = await _http.GetStringAsync($"{DefaultMcpBaseUrl}/docs"); var widgets = new List(); var lines = yaml.Split('\n'); @@ -66,7 +135,6 @@ public static async Task> GetWidgetsAsync() var linkLine = lines[i + 1].Trim(); var link = linkLine.StartsWith("link: ") ? linkLine["link: ".Length..] : ""; - // "Ivy.Widgets.Common.Button" → parts = ["Ivy", "Widgets", "Common", "Button"] var parts = fullName.Split('.'); if (parts.Length < 4) continue; @@ -79,11 +147,50 @@ public static async Task> GetWidgetsAsync() return widgets.OrderBy(w => w.Category).ThenBy(w => w.Name).ToList(); } + /// + /// Widgets from /docs merged with distinct widget names stored in the database. + /// If the docs API fails or returns nothing, rows still appear from DB (fixes empty table when offline). + /// + public static async Task> GetMergedWidgetCatalogAsync( + AppDbContextFactory factory, + CancellationToken ct = default) + { + List api = []; + try + { + api = await GetWidgetsAsync(); + } + catch + { + // e.g. network blocked in browser / server + } + + await using var ctx = factory.CreateDbContext(); + var fromDb = await ctx.Questions + .AsNoTracking() + .Where(q => q.Widget != "") + .GroupBy(q => q.Widget) + .Select(g => new { Widget = g.Key, Category = g.Select(x => x.Category).FirstOrDefault() }) + .ToListAsync(ct); + + var byName = api.ToDictionary(w => w.Name, w => w); + foreach (var row in fromDb) + { + if (!byName.ContainsKey(row.Widget)) + byName[row.Widget] = new IvyWidget(row.Widget, row.Category ?? "", ""); + } + + return byName.Values + .OrderBy(w => w.Category) + .ThenBy(w => w.Name) + .ToList(); + } + /// /// Fetches the Markdown documentation for a specific widget. /// public static async Task GetWidgetDocsAsync(string docLink) { - return await _http.GetStringAsync($"{McpBase}/{docLink}"); + return await _http.GetStringAsync($"{DefaultMcpBaseUrl}/{docLink}"); } } diff --git a/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs index cf0cc2f3..4f656a9d 100644 --- a/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs +++ b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs @@ -1,39 +1,45 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; using IvyAskStatistics.Connections; using Microsoft.EntityFrameworkCore; namespace IvyAskStatistics.Services; +/// +/// Generates test questions via the same Ivy Ask HTTP API as the runner +/// (GET {base}/questions?question=... on mcp.ivy.app). +/// Uses meta-prompts that ask for a JSON array of question strings; Ivy Ask answers from docs + model. +/// Does not embed full widget markdown in the URL (GET length limits). +/// public static class QuestionGeneratorService { - private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(60) }; - private const string OpenAiUrl = "https://api.openai.com/v1/chat/completions"; - /// /// Generates 10 questions per difficulty (easy / medium / hard) for a widget - /// and saves them to the database, replacing any previously generated ones. + /// and saves them to the database, replacing any previously MCP-generated ones. /// public static async Task GenerateAndSaveAsync( IvyWidget widget, - string openAiKey, AppDbContextFactory factory, - IProgress? progress = null) + string askBaseUrl, + IProgress? progress = null, + CancellationToken ct = default) { - var docs = await IvyAskService.GetWidgetDocsAsync(widget.DocLink); - foreach (var difficulty in new[] { "easy", "medium", "hard" }) { - progress?.Report($"Generating {difficulty} questions for {widget.Name}…"); + progress?.Report($"Ivy Ask: {difficulty} questions for {widget.Name}…"); + + var prompt = BuildMetaPrompt(widget.Name, widget.Category, difficulty); + var body = await IvyAskService.FetchAnswerBodyAsync(askBaseUrl, prompt, ct) + ?? throw new InvalidOperationException("Ivy Ask returned no body (check network and base URL)."); - var questions = await GenerateQuestionsAsync(widget.Name, docs, difficulty, openAiKey); + var questions = IvyAskService.ParseQuestionStringsFromBody(body, 10); + if (questions.Count == 0) + throw new InvalidOperationException( + "Could not parse questions from Ivy Ask response. Try again or inspect logs."); await using var ctx = factory.CreateDbContext(); var existing = await ctx.Questions - .Where(q => q.Widget == widget.Name && q.Difficulty == difficulty && q.Source == "generated") - .ToListAsync(); + .Where(q => q.Widget == widget.Name && q.Difficulty == difficulty && q.Source == "ivy_ask_meta") + .ToListAsync(ct); ctx.Questions.RemoveRange(existing); ctx.Questions.AddRange(questions.Select(q => new QuestionEntity @@ -42,68 +48,36 @@ public static async Task GenerateAndSaveAsync( Category = widget.Category, Difficulty = difficulty, QuestionText = q, - Source = "generated", + Source = "ivy_ask_meta", CreatedAt = DateTime.UtcNow })); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); } } - private static async Task> GenerateQuestionsAsync( - string widgetName, - string docs, - string difficulty, - string openAiKey) + private static string BuildMetaPrompt(string widgetName, string category, string difficulty) { - var prompt = $$""" - You are an expert in the Ivy Framework for C#. - - Here is the documentation for the "{{widgetName}}" widget: + var difficultyHint = difficulty switch + { + "easy" => "basic usage, creating the widget, simple properties", + "medium" => "events, styling, configuration, common patterns", + _ => "advanced composition, edge cases, integration with other Ivy widgets" + }; - {{docs}} + return $""" + You are generating automated test questions for the Ivy UI framework documentation system. - Generate exactly 10 {{difficulty}} questions that a developer might ask about this widget. + Widget name: {widgetName} + Widget category (folder): {category} + Difficulty: {difficulty} — {difficultyHint} - DIFFICULTY GUIDELINES: - - easy: Basic usage ("how to create", "how to show", simple properties) - - medium: Specific features, configuration, event handlers, styling - - hard: Advanced patterns, combining with other widgets, dynamic data, edge cases + Generate exactly 10 distinct questions that a C# developer might ask about this Ivy widget. + Each question must be a single sentence, under 25 words, about the "{widgetName}" widget only. + Do not combine multiple unrelated topics in one question. - Rules: - 1. Each question must be specific and about the {{widgetName}} widget in Ivy - 2. No compound questions - one concept per question - 3. Keep questions concise (under 20 words) - 4. Return ONLY valid JSON in this exact format: {"questions": ["q1", "q2", "q3"]} + Respond with ONLY a JSON array of 10 strings (no markdown fences, no keys, no commentary). + Example shape: ["question1?", "question2?", ...] """; - - using var request = new HttpRequestMessage(HttpMethod.Post, OpenAiUrl); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", openAiKey); - request.Content = JsonContent.Create(new - { - model = "gpt-4o-mini", - messages = new[] { new { role = "user", content = prompt } }, - temperature = 0.7, - response_format = new { type = "json_object" } - }); - - var response = await _http.SendAsync(request); - response.EnsureSuccessStatusCode(); - - var root = await response.Content.ReadFromJsonAsync(); - var content = root - .GetProperty("choices")[0] - .GetProperty("message") - .GetProperty("content") - .GetString() ?? "{}"; - - var parsed = JsonSerializer.Deserialize(content); - return parsed - .GetProperty("questions") - .EnumerateArray() - .Select(q => q.GetString() ?? "") - .Where(q => !string.IsNullOrWhiteSpace(q)) - .Take(10) - .ToList(); } } From 4cc3f866a08e930ceea8298cbd0be7d2ac213cfd Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sun, 29 Mar 2026 18:17:25 +0300 Subject: [PATCH 06/39] refactor: Streamline QuestionsApp by introducing a dedicated method for loading widget table data, enhancing state management for question deletion, and improving overall code clarity --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 268 +++++++++++------- 1 file changed, 169 insertions(+), 99 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 4d2ca895..95130e94 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -1,10 +1,12 @@ namespace IvyAskStatistics.Apps; -file record WidgetTableData(List Rows, List Catalog, int QueryKey); +internal sealed record WidgetTableData(List Rows, List Catalog, int QueryKey); [App(icon: Icons.Database, title: "Questions")] public class QuestionsApp : ViewBase { + private const int TableQueryKey = 0; + public override object? Build() { var factory = UseService(); @@ -14,87 +16,16 @@ public class QuestionsApp : ViewBase var generateRequest = UseState(null); var generatingWidget = UseState(""); var generatingStatus = UseState(""); - var refreshTick = UseState(0); var pendingRefreshFor = UseState(""); // widget whose table row is reloading after generation + var deleteRequest = UseState(null); // widget name to delete questions for (UseEffect runs DB work) var refreshToken = UseRefreshToken(); var (alertView, showAlert) = UseAlert(); var tableQuery = UseQuery( - key: refreshTick.Value, - fetcher: async (queryKey, ct) => - { - await using var ctx = factory.CreateDbContext(); - - var grouped = await ctx.Questions - .AsNoTracking() - .GroupBy(q => new { q.Widget, q.Category, q.Difficulty }) - .Select(g => new - { - g.Key.Widget, - g.Key.Category, - g.Key.Difficulty, - Count = g.Count(), - MaxDate = g.Max(x => x.CreatedAt) - }) - .ToListAsync(ct); - - var countsByWidget = grouped - .GroupBy(x => x.Widget) - .ToDictionary( - g => g.Key, - g => ( - category: g.Select(x => x.Category).FirstOrDefault() ?? "", - easy: g.FirstOrDefault(x => x.Difficulty == "easy")?.Count ?? 0, - medium: g.FirstOrDefault(x => x.Difficulty == "medium")?.Count ?? 0, - hard: g.FirstOrDefault(x => x.Difficulty == "hard")?.Count ?? 0, - updatedAt: g.Max(x => x.MaxDate) - )); - - List catalog = []; - try { catalog = await IvyAskService.GetWidgetsAsync(); } - catch { /* offline */ } - - var byName = catalog.ToDictionary(w => w.Name); - foreach (var (widget, info) in countsByWidget) - if (!byName.ContainsKey(widget)) - byName[widget] = new IvyWidget(widget, info.category, ""); - - var rows = byName.Values - .OrderBy(w => string.IsNullOrEmpty(w.Category) ? "zzz" : w.Category) - .ThenBy(w => w.Name) - .Select(w => - { - var c = countsByWidget.GetValueOrDefault(w.Name); - var category = string.IsNullOrEmpty(w.Category) ? "Unclassified" : w.Category; - var updated = c.updatedAt == default - ? "—" - : c.updatedAt.ToLocalTime().ToString("dd MMM yyyy, HH:mm"); - return new WidgetRow(w.Name, category, c.easy, c.medium, c.hard, updated, ""); - }) - .ToList(); - - return new WidgetTableData(rows, catalog, queryKey); - }, + key: TableQueryKey, + fetcher: async (qk, ct) => await LoadWidgetTableDataAsync(factory, qk, ct), options: new QueryOptions { KeepPrevious = true }); - // Clear "Updating…" once the query result matches the current refreshTick (KeepPrevious-safe) - UseEffect(async () => - { - if (string.IsNullOrEmpty(pendingRefreshFor.Value)) return; - var targetKey = refreshTick.Value; - for (var guard = 0; guard < 600; guard++) - { - if (tableQuery.Value?.QueryKey == targetKey && !tableQuery.Loading) - break; - await Task.Delay(16); - } - if (!string.IsNullOrEmpty(pendingRefreshFor.Value)) - { - pendingRefreshFor.Set(""); - refreshToken.Refresh(); - } - }, [refreshTick.ToTrigger()]); - UseEffect(() => { refreshToken.Refresh(); }, [generatingWidget.ToTrigger(), generatingStatus.ToTrigger()]); @@ -112,7 +43,12 @@ await QuestionGeneratorService.GenerateAndSaveAsync( new Progress(msg => generatingStatus.Set(msg))); pendingRefreshFor.Set(widget.Name); - refreshTick.Set(refreshTick.Value + 1); + refreshToken.Refresh(); + + var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); + tableQuery.Mutator.Mutate(fresh, revalidate: false); + pendingRefreshFor.Set(""); + refreshToken.Refresh(); client.Toast($"Generated 30 questions for {widget.Name}"); } catch (Exception ex) @@ -128,14 +64,46 @@ await QuestionGeneratorService.GenerateAndSaveAsync( } }, [generateRequest.ToTrigger()]); + UseEffect(async () => + { + var widgetName = deleteRequest.Value; + if (string.IsNullOrEmpty(widgetName)) return; + + try + { + await using var ctx = factory.CreateDbContext(); + var list = await ctx.Questions.Where(q => q.Widget == widgetName).ToListAsync(); + if (list.Count == 0) + { + client.Toast("No questions to delete."); + return; + } + ctx.Questions.RemoveRange(list); + await ctx.SaveChangesAsync(); + + var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); + tableQuery.Mutator.Mutate(fresh, revalidate: false); + refreshToken.Refresh(); + client.Toast($"Deleted {list.Count} question(s) for \"{widgetName}\"."); + } + catch (Exception ex) + { + client.Toast($"Error: {ex.Message}"); + } + finally + { + deleteRequest.Set(null); + } + }, [deleteRequest.ToTrigger()]); + // ── Derived state ───────────────────────────────────────────────────── var baseRows = tableQuery.Value?.Rows ?? []; var catalog = tableQuery.Value?.Catalog ?? []; var generating = generatingWidget.Value; var isGenerating = !string.IsNullOrEmpty(generating); - // Only the initial mount uses the full-screen loader. After refreshTick bumps (post-generation - // refetch), never swap the whole view for Loading — avoids the table vanishing mid-flow. - var firstLoad = tableQuery.Loading && tableQuery.Value == null && refreshTick.Value == 0; + var isDeleting = !string.IsNullOrEmpty(deleteRequest.Value); + // Full-screen loader only before the first successful query payload (KeepPrevious keeps rows during Mutate). + var firstLoad = tableQuery.Loading && tableQuery.Value == null; var pendingRow = pendingRefreshFor.Value; static string IdleStatus(WidgetRow r) @@ -166,7 +134,7 @@ static string IdleStatus(WidgetRow r) var table = rows.AsQueryable() .ToDataTable(r => r.Widget) .RefreshToken(refreshToken) - // Stable key: do not tie to refreshTick — that remounted the whole table after each generation. + // Stable key: Mutator.Mutate + RefreshToken update rows; changing Key remounted the grid and made it disappear on delete. .Key("questions-widgets") .Height(Size.Full()) .Header(r => r.Widget, "Widget") @@ -184,32 +152,76 @@ static string IdleStatus(WidgetRow r) .Width(r => r.LastUpdated, Size.Px(170)) .Width(r => r.Status, Size.Px(280)) .RowActions( - MenuItem.Default(Icons.Sparkles, "generate").Label("Generate questions").Tag("generate")) + MenuItem.Default(Icons.Sparkles, "generate").Label("Generate questions").Tag("generate"), + MenuItem.Default(Icons.Trash2, "delete").Label("Delete questions").Tag("delete")) .OnRowAction(e => { var args = e.Value; - if (args?.Tag?.ToString() != "generate") return ValueTask.CompletedTask; - if (isGenerating) + var tag = args?.Tag?.ToString(); + if (string.IsNullOrEmpty(tag)) return ValueTask.CompletedTask; + + if (tag == "generate") { - client.Toast("Already generating — please wait"); + if (isDeleting) + { + client.Toast("Please wait — delete in progress"); + return ValueTask.CompletedTask; + } + if (isGenerating) + { + client.Toast("Already generating — please wait"); + return ValueTask.CompletedTask; + } + var genName = args.Id?.ToString() ?? ""; + var widget = catalog.FirstOrDefault(w => w.Name == genName) + ?? new IvyWidget(genName, rows.FirstOrDefault(r => r.Widget == genName)?.Category ?? "", ""); + showAlert( + $"Generate 30 questions for the \"{widget.Name}\" widget?\n\nIvy Ask will be called three times (easy / medium / hard). Any previously generated questions for this widget will be replaced.", + result => + { + if (!result.IsOk()) return; + generatingWidget.Set(widget.Name); + generatingStatus.Set("Starting…"); + generateRequest.Set(widget); + refreshToken.Refresh(); + }, + "Generate questions", + AlertButtonSet.OkCancel); return ValueTask.CompletedTask; } - var name = args.Id?.ToString() ?? ""; - var widget = catalog.FirstOrDefault(w => w.Name == name) - ?? new IvyWidget(name, rows.FirstOrDefault(r => r.Widget == name)?.Category ?? "", ""); - showAlert( - $"Generate 30 questions for the \"{widget.Name}\" widget?\n\nIvy Ask will be called three times (easy / medium / hard). Any previously generated questions for this widget will be replaced.", - result => + + if (tag == "delete") + { + if (isDeleting) { - if (!result.IsOk()) return; - // Same pattern as pr-staging-deploy: update UI immediately on confirm, then run async work. - generatingWidget.Set(widget.Name); - generatingStatus.Set("Starting…"); - generateRequest.Set(widget); - refreshToken.Refresh(); - }, - "Generate questions", - AlertButtonSet.OkCancel); + client.Toast("Delete already in progress — please wait"); + return ValueTask.CompletedTask; + } + if (isGenerating) + { + client.Toast("Already generating — please wait"); + return ValueTask.CompletedTask; + } + var delName = args.Id?.ToString() ?? ""; + var row = rows.FirstOrDefault(r => r.Widget == delName); + var n = row == null ? 0 : row.Easy + row.Medium + row.Hard; + if (n == 0) + { + client.Toast("No questions to delete for this widget."); + return ValueTask.CompletedTask; + } + showAlert( + $"Delete all {n} question(s) for the \"{delName}\" widget?\n\nThis cannot be undone.", + result => + { + if (!result.IsOk()) return; + deleteRequest.Set(delName); + }, + "Delete questions", + AlertButtonSet.OkCancel); + return ValueTask.CompletedTask; + } + return ValueTask.CompletedTask; }) .Config(config => @@ -224,4 +236,62 @@ static string IdleStatus(WidgetRow r) | alertView | table; } + + private static async Task LoadWidgetTableDataAsync( + AppDbContextFactory factory, + int queryKey, + CancellationToken ct) + { + await using var ctx = factory.CreateDbContext(); + + var grouped = await ctx.Questions + .AsNoTracking() + .GroupBy(q => new { q.Widget, q.Category, q.Difficulty }) + .Select(g => new + { + g.Key.Widget, + g.Key.Category, + g.Key.Difficulty, + Count = g.Count(), + MaxDate = g.Max(x => x.CreatedAt) + }) + .ToListAsync(ct); + + var countsByWidget = grouped + .GroupBy(x => x.Widget) + .ToDictionary( + g => g.Key, + g => ( + category: g.Select(x => x.Category).FirstOrDefault() ?? "", + easy: g.FirstOrDefault(x => x.Difficulty == "easy")?.Count ?? 0, + medium: g.FirstOrDefault(x => x.Difficulty == "medium")?.Count ?? 0, + hard: g.FirstOrDefault(x => x.Difficulty == "hard")?.Count ?? 0, + updatedAt: g.Max(x => x.MaxDate) + )); + + List catalog = []; + try { catalog = await IvyAskService.GetWidgetsAsync(); } + catch { /* offline */ } + + var byName = catalog.ToDictionary(w => w.Name); + foreach (var (widget, info) in countsByWidget) + if (!byName.ContainsKey(widget)) + byName[widget] = new IvyWidget(widget, info.category, ""); + + var rows = byName.Values + .OrderBy(w => string.IsNullOrEmpty(w.Category) ? "zzz" : w.Category) + .ThenBy(w => w.Name) + .Select(w => + { + var c = countsByWidget.GetValueOrDefault(w.Name); + var category = string.IsNullOrEmpty(w.Category) ? "Unclassified" : w.Category; + var updated = c.updatedAt == default + ? "—" + : c.updatedAt.ToLocalTime().ToString("dd MMM yyyy, HH:mm"); + return new WidgetRow(w.Name, category, c.easy, c.medium, c.hard, updated, ""); + }) + .ToList(); + + return new WidgetTableData(rows, catalog, queryKey); + } } From 7b24b1a9fdf818266b21dce31e9b7a9454ecff74 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sun, 29 Mar 2026 19:28:03 +0300 Subject: [PATCH 07/39] feat: Add WidgetQuestionsDialog for displaying questions related to a specific widget, enhancing user interaction in QuestionsApp --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 19 +++- .../Apps/WidgetQuestionsDialog.cs | 96 +++++++++++++++++++ .../Models/QuestionDetailRow.cs | 9 ++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs create mode 100644 project-demos/ivy-ask-statistics/Models/QuestionDetailRow.cs diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 95130e94..2cf7215d 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -18,6 +18,8 @@ public class QuestionsApp : ViewBase var generatingStatus = UseState(""); var pendingRefreshFor = UseState(""); // widget whose table row is reloading after generation var deleteRequest = UseState(null); // widget name to delete questions for (UseEffect runs DB work) + var viewDialogOpen = UseState(false); + var viewDialogWidget = UseState(""); var refreshToken = UseRefreshToken(); var (alertView, showAlert) = UseAlert(); @@ -152,6 +154,7 @@ static string IdleStatus(WidgetRow r) .Width(r => r.LastUpdated, Size.Px(170)) .Width(r => r.Status, Size.Px(280)) .RowActions( + MenuItem.Default(Icons.List, "questions").Label("View questions").Tag("questions"), MenuItem.Default(Icons.Sparkles, "generate").Label("Generate questions").Tag("generate"), MenuItem.Default(Icons.Trash2, "delete").Label("Delete questions").Tag("delete")) .OnRowAction(e => @@ -160,6 +163,15 @@ static string IdleStatus(WidgetRow r) var tag = args?.Tag?.ToString(); if (string.IsNullOrEmpty(tag)) return ValueTask.CompletedTask; + if (tag == "questions") + { + var viewName = args.Id?.ToString() ?? ""; + if (string.IsNullOrEmpty(viewName)) return ValueTask.CompletedTask; + viewDialogWidget.Set(viewName); + viewDialogOpen.Set(true); + return ValueTask.CompletedTask; + } + if (tag == "generate") { if (isDeleting) @@ -232,9 +244,14 @@ static string IdleStatus(WidgetRow r) config.ShowIndexColumn = false; }); + object? questionsDialog = viewDialogOpen.Value && !string.IsNullOrEmpty(viewDialogWidget.Value) + ? new WidgetQuestionsDialog(viewDialogOpen, viewDialogWidget.Value) + : null; + return Layout.Vertical().Height(Size.Full()) | alertView - | table; + | table + | questionsDialog; } private static async Task LoadWidgetTableDataAsync( diff --git a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs new file mode 100644 index 00000000..3677dd68 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs @@ -0,0 +1,96 @@ +namespace IvyAskStatistics.Apps; + +/// Modal listing all DB questions for a single widget. +internal sealed class WidgetQuestionsDialog(IState isOpen, string widgetName) : ViewBase +{ + public override object? Build() + { + var factory = UseService(); + + var tableQuery = UseQuery, string>( + key: widgetName, + fetcher: async (name, ct) => await LoadQuestionsAsync(factory, name, ct), + options: new QueryOptions { KeepPrevious = true }); + + var firstLoad = tableQuery.Loading && tableQuery.Value == null; + var rows = tableQuery.Value ?? []; + + void Close() => isOpen.Set(false); + + object body; + if (firstLoad) + { + body = Layout.Center() + | new Icon(Icons.Loader) + | Text.Muted("Loading…"); + } + else if (rows.Count == 0) + { + body = new Callout( + $"No questions in the database for \"{widgetName}\".", + variant: CalloutVariant.Info); + } + else + { + body = QuestionsTable(rows, widgetName); + } + + var title = firstLoad || rows.Count == 0 + ? $"Questions — {widgetName}" + : $"Questions — {widgetName} ({rows.Count})"; + + var footer = new DialogFooter(new Button("Close").OnClick(_ => Close())); + + return new Dialog( + onClose: _ => Close(), + header: new DialogHeader(title), + body: new DialogBody(body), + footer: footer) + .Width(Size.Units(250)); + } + + private static object QuestionsTable(List rows, string widgetName) => + rows.AsQueryable() + .ToDataTable(r => r.Id) + .Key($"widget-questions-{widgetName}") + .Height(Size.Full()) + .Header(r => r.Difficulty, "Difficulty") + .Header(r => r.Category, "Category") + .Header(r => r.QuestionText, "Question") + .Header(r => r.Source, "Source") + .Header(r => r.CreatedAt, "Created") + .Width(r => r.Difficulty, Size.Px(90)) + .Width(r => r.Category, Size.Px(140)) + .Width(r => r.Source, Size.Px(90)) + .Width(r => r.CreatedAt, Size.Px(170)) + .Config(c => + { + c.AllowSorting = true; + c.AllowFiltering = true; + c.ShowSearch = true; + c.ShowIndexColumn = false; + }); + + private static async Task> LoadQuestionsAsync( + AppDbContextFactory factory, + string name, + CancellationToken ct) + { + await using var ctx = factory.CreateDbContext(); + + return await ctx.Questions + .AsNoTracking() + .Where(q => q.Widget == name) + .OrderBy(q => q.Difficulty) + .ThenBy(q => q.Category) + .ThenBy(q => q.CreatedAt) + .Select(q => new QuestionDetailRow( + q.Id, + q.Difficulty, + q.Category, + q.QuestionText ?? "", + q.Source, + q.CreatedAt.ToLocalTime().ToString("dd MMM yyyy, HH:mm", CultureInfo.CurrentCulture))) + .ToListAsync(ct); + } +} diff --git a/project-demos/ivy-ask-statistics/Models/QuestionDetailRow.cs b/project-demos/ivy-ask-statistics/Models/QuestionDetailRow.cs new file mode 100644 index 00000000..6e373186 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Models/QuestionDetailRow.cs @@ -0,0 +1,9 @@ +namespace IvyAskStatistics.Models; + +public record QuestionDetailRow( + Guid Id, + string Difficulty, + string Category, + string QuestionText, + string Source, + string CreatedAt); From 74fb90c93bceda94c3d53249700513588c8e3d8b Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 30 Mar 2026 19:17:03 +0300 Subject: [PATCH 08/39] feat: Introduce QuestionEditSheet for editing questions and enhance WidgetQuestionsDialog with edit functionality, improving user experience in QuestionsApp --- .../Apps/QuestionEditSheet.cs | 63 +++++++++ .../ivy-ask-statistics/Apps/QuestionsApp.cs | 12 +- .../Apps/WidgetQuestionsDialog.cs | 121 +++++++++++++----- .../ivy-ask-statistics/GlobalUsings.cs | 1 + 4 files changed, 165 insertions(+), 32 deletions(-) create mode 100644 project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs new file mode 100644 index 00000000..3ef05bb9 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs @@ -0,0 +1,63 @@ +namespace IvyAskStatistics.Apps; + +internal sealed class QuestionEditSheet(IState isOpen, Guid questionId, Action onSaved) : ViewBase +{ + private record EditRequest + { + [Required] + public string QuestionText { get; init; } = ""; + + [Required] + public string Difficulty { get; init; } = ""; + + public string Category { get; init; } = ""; + } + + public override object? Build() + { + var factory = UseService(); + + var questionQuery = UseQuery( + key: questionId, + fetcher: async (id, ct) => + { + await using var ctx = factory.CreateDbContext(); + return await ctx.Questions.AsNoTracking().FirstOrDefaultAsync(q => q.Id == id, ct); + }); + + if (questionQuery.Loading || questionQuery.Value == null) + return Skeleton.Form().ToSheet(isOpen, "Edit Question"); + + var q = questionQuery.Value; + + var form = new EditRequest + { + QuestionText = q.QuestionText ?? "", + Difficulty = q.Difficulty, + Category = q.Category, + }; + + var difficulties = new[] { "easy", "medium", "hard" }.ToOptions(); + + return form + .ToForm() + .Builder(f => f.QuestionText, f => f.ToTextareaInput()) + .Builder(f => f.Difficulty, f => f.ToSelectInput(difficulties)) + .OnSubmit(OnSubmit) + .ToSheet(isOpen, "Edit Question"); + + async Task OnSubmit(EditRequest? request) + { + if (request == null) return; + await using var ctx = factory.CreateDbContext(); + var entity = await ctx.Questions.FirstOrDefaultAsync(e => e.Id == questionId); + if (entity == null) return; + entity.QuestionText = request.QuestionText.Trim(); + entity.Difficulty = request.Difficulty; + entity.Category = request.Category.Trim(); + await ctx.SaveChangesAsync(); + isOpen.Set(false); + onSaved(); + } + } +} diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 2cf7215d..43ea7d32 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -20,6 +20,9 @@ public class QuestionsApp : ViewBase var deleteRequest = UseState(null); // widget name to delete questions for (UseEffect runs DB work) var viewDialogOpen = UseState(false); var viewDialogWidget = UseState(""); + var editSheetOpen = UseState(false); + var editQuestionId = UseState(Guid.Empty); + var dialogRefresh = UseRefreshToken(); var refreshToken = UseRefreshToken(); var (alertView, showAlert) = UseAlert(); @@ -245,13 +248,18 @@ static string IdleStatus(WidgetRow r) }); object? questionsDialog = viewDialogOpen.Value && !string.IsNullOrEmpty(viewDialogWidget.Value) - ? new WidgetQuestionsDialog(viewDialogOpen, viewDialogWidget.Value) + ? new WidgetQuestionsDialog(viewDialogOpen, viewDialogWidget.Value, editSheetOpen, editQuestionId, dialogRefresh) + : null; + + object? editSheet = editSheetOpen.Value && editQuestionId.Value != Guid.Empty + ? new QuestionEditSheet(editSheetOpen, editQuestionId.Value, () => dialogRefresh.Refresh()) : null; return Layout.Vertical().Height(Size.Full()) | alertView | table - | questionsDialog; + | questionsDialog + | editSheet; } private static async Task LoadWidgetTableDataAsync( diff --git a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs index 3677dd68..57ea34aa 100644 --- a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs +++ b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs @@ -1,22 +1,52 @@ namespace IvyAskStatistics.Apps; /// Modal listing all DB questions for a single widget. -internal sealed class WidgetQuestionsDialog(IState isOpen, string widgetName) : ViewBase +internal sealed class WidgetQuestionsDialog( + IState isOpen, + string widgetName, + IState editSheetOpen, + IState editQuestionId, + RefreshToken dialogRefresh) : ViewBase { public override object? Build() { var factory = UseService(); + var client = UseService(); + + var (alertView, showAlert) = UseAlert(); var tableQuery = UseQuery, string>( key: widgetName, fetcher: async (name, ct) => await LoadQuestionsAsync(factory, name, ct), options: new QueryOptions { KeepPrevious = true }); + UseEffect(() => { tableQuery.Mutator.Revalidate(); }, [dialogRefresh.ToTrigger()]); + var firstLoad = tableQuery.Loading && tableQuery.Value == null; var rows = tableQuery.Value ?? []; void Close() => isOpen.Set(false); + async Task DeleteAsync(Guid id) + { + try + { + await using var ctx = factory.CreateDbContext(); + var entity = await ctx.Questions.FirstOrDefaultAsync(q => q.Id == id); + if (entity != null) + { + ctx.Questions.Remove(entity); + await ctx.SaveChangesAsync(); + } + var updated = rows.Where(r => r.Id != id).ToList(); + tableQuery.Mutator.Mutate(updated, revalidate: false); + } + catch (Exception ex) + { + client.Toast($"Error: {ex.Message}"); + } + } + object body; if (firstLoad) { @@ -32,7 +62,58 @@ internal sealed class WidgetQuestionsDialog(IState isOpen, string widgetNa } else { - body = QuestionsTable(rows, widgetName); + body = rows.AsQueryable() + .ToDataTable(r => r.Id) + .Key($"widget-questions-{widgetName}") + .Height(Size.Units(120)) + .Header(r => r.Difficulty, "Difficulty") + .Header(r => r.Category, "Category") + .Header(r => r.QuestionText, "Question") + .Header(r => r.Source, "Source") + .Header(r => r.CreatedAt, "Created") + .Width(r => r.Difficulty, Size.Px(80)) + .Width(r => r.QuestionText, Size.Px(340)) + .Width(r => r.Source, Size.Px(100)) + .Width(r => r.CreatedAt, Size.Px(170)) + .Hidden(r => r.Id) + .RowActions( + MenuItem.Default(Icons.Pencil, "edit").Label("Edit").Tag("edit"), + MenuItem.Default(Icons.Trash2, "delete").Label("Delete").Tag("delete")) + .OnRowAction(e => + { + var args = e.Value; + var tag = args?.Tag?.ToString(); + if (!Guid.TryParse(args?.Id?.ToString(), out var id)) return ValueTask.CompletedTask; + + if (tag == "edit") + { + editQuestionId.Set(id); + editSheetOpen.Set(true); + } + else if (tag == "delete") + { + var text = rows.FirstOrDefault(r => r.Id == id)?.QuestionText ?? ""; + var preview = text.Length > 60 ? text[..60] + "…" : text; + showAlert( + $"Delete this question?\n\n\"{preview}\"", + async result => + { + if (!result.IsOk()) return; + await DeleteAsync(id); + }, + "Delete question", + AlertButtonSet.OkCancel); + } + + return ValueTask.CompletedTask; + }) + .Config(c => + { + c.AllowSorting = true; + c.AllowFiltering = true; + c.ShowSearch = true; + c.ShowIndexColumn = false; + }); } var title = firstLoad || rows.Count == 0 @@ -41,36 +122,16 @@ internal sealed class WidgetQuestionsDialog(IState isOpen, string widgetNa var footer = new DialogFooter(new Button("Close").OnClick(_ => Close())); - return new Dialog( - onClose: _ => Close(), - header: new DialogHeader(title), - body: new DialogBody(body), - footer: footer) - .Width(Size.Units(250)); + return new Fragment( + alertView, + new Dialog( + onClose: _ => Close(), + header: new DialogHeader(title), + body: new DialogBody(body), + footer: footer) + .Width(Size.Units(240))); } - private static object QuestionsTable(List rows, string widgetName) => - rows.AsQueryable() - .ToDataTable(r => r.Id) - .Key($"widget-questions-{widgetName}") - .Height(Size.Full()) - .Header(r => r.Difficulty, "Difficulty") - .Header(r => r.Category, "Category") - .Header(r => r.QuestionText, "Question") - .Header(r => r.Source, "Source") - .Header(r => r.CreatedAt, "Created") - .Width(r => r.Difficulty, Size.Px(90)) - .Width(r => r.Category, Size.Px(140)) - .Width(r => r.Source, Size.Px(90)) - .Width(r => r.CreatedAt, Size.Px(170)) - .Config(c => - { - c.AllowSorting = true; - c.AllowFiltering = true; - c.ShowSearch = true; - c.ShowIndexColumn = false; - }); - private static async Task> LoadQuestionsAsync( AppDbContextFactory factory, string name, diff --git a/project-demos/ivy-ask-statistics/GlobalUsings.cs b/project-demos/ivy-ask-statistics/GlobalUsings.cs index b79e8b7c..f89be426 100644 --- a/project-demos/ivy-ask-statistics/GlobalUsings.cs +++ b/project-demos/ivy-ask-statistics/GlobalUsings.cs @@ -1,4 +1,5 @@ global using Ivy; +global using System.ComponentModel.DataAnnotations; global using IvyAskStatistics.Models; global using IvyAskStatistics.Services; global using IvyAskStatistics.Connections; From 8c355764280f5d3402db7952e86574252c645212 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 30 Mar 2026 19:39:31 +0300 Subject: [PATCH 09/39] refactor: Remove unused parameters from QuestionEditSheet and WidgetQuestionsDialog, streamline data fetching logic, and enhance query service integration for improved performance in QuestionsApp --- .../Apps/QuestionEditSheet.cs | 8 ++++--- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 15 ++++++++---- .../Apps/WidgetQuestionsDialog.cs | 24 ++++++++++++------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs index 3ef05bb9..588c951c 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs @@ -1,6 +1,6 @@ namespace IvyAskStatistics.Apps; -internal sealed class QuestionEditSheet(IState isOpen, Guid questionId, Action onSaved) : ViewBase +internal sealed class QuestionEditSheet(IState isOpen, Guid questionId) : ViewBase { private record EditRequest { @@ -15,7 +15,8 @@ private record EditRequest public override object? Build() { - var factory = UseService(); + var factory = UseService(); + var queryService = UseService(); var questionQuery = UseQuery( key: questionId, @@ -56,8 +57,9 @@ async Task OnSubmit(EditRequest? request) entity.Difficulty = request.Difficulty; entity.Category = request.Category.Trim(); await ctx.SaveChangesAsync(); + queryService.RevalidateByTag(("widget-questions", entity.Widget)); + queryService.RevalidateByTag("widget-summary"); isOpen.Set(false); - onSaved(); } } } diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 43ea7d32..b1fc5dba 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -22,14 +22,19 @@ public class QuestionsApp : ViewBase var viewDialogWidget = UseState(""); var editSheetOpen = UseState(false); var editQuestionId = UseState(Guid.Empty); - var dialogRefresh = UseRefreshToken(); var refreshToken = UseRefreshToken(); var (alertView, showAlert) = UseAlert(); var tableQuery = UseQuery( key: TableQueryKey, - fetcher: async (qk, ct) => await LoadWidgetTableDataAsync(factory, qk, ct), - options: new QueryOptions { KeepPrevious = true }); + fetcher: async (qk, ct) => + { + var result = await LoadWidgetTableDataAsync(factory, qk, ct); + refreshToken.Refresh(); + return result; + }, + options: new QueryOptions { KeepPrevious = true }, + tags: ["widget-summary"]); UseEffect(() => { refreshToken.Refresh(); }, [generatingWidget.ToTrigger(), generatingStatus.ToTrigger()]); @@ -248,11 +253,11 @@ static string IdleStatus(WidgetRow r) }); object? questionsDialog = viewDialogOpen.Value && !string.IsNullOrEmpty(viewDialogWidget.Value) - ? new WidgetQuestionsDialog(viewDialogOpen, viewDialogWidget.Value, editSheetOpen, editQuestionId, dialogRefresh) + ? new WidgetQuestionsDialog(viewDialogOpen, viewDialogWidget.Value, editSheetOpen, editQuestionId) : null; object? editSheet = editSheetOpen.Value && editQuestionId.Value != Guid.Empty - ? new QuestionEditSheet(editSheetOpen, editQuestionId.Value, () => dialogRefresh.Refresh()) + ? new QuestionEditSheet(editSheetOpen, editQuestionId.Value) : null; return Layout.Vertical().Height(Size.Full()) diff --git a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs index 57ea34aa..b8dd3fcb 100644 --- a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs +++ b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs @@ -5,22 +5,27 @@ internal sealed class WidgetQuestionsDialog( IState isOpen, string widgetName, IState editSheetOpen, - IState editQuestionId, - RefreshToken dialogRefresh) : ViewBase + IState editQuestionId) : ViewBase { public override object? Build() { - var factory = UseService(); - var client = UseService(); + var factory = UseService(); + var client = UseService(); + var queryService = UseService(); + var refreshToken = UseRefreshToken(); var (alertView, showAlert) = UseAlert(); var tableQuery = UseQuery, string>( key: widgetName, - fetcher: async (name, ct) => await LoadQuestionsAsync(factory, name, ct), - options: new QueryOptions { KeepPrevious = true }); - - UseEffect(() => { tableQuery.Mutator.Revalidate(); }, [dialogRefresh.ToTrigger()]); + fetcher: async (name, ct) => + { + var result = await LoadQuestionsAsync(factory, name, ct); + refreshToken.Refresh(); + return result; + }, + options: new QueryOptions { KeepPrevious = true }, + tags: [("widget-questions", widgetName)]); var firstLoad = tableQuery.Loading && tableQuery.Value == null; var rows = tableQuery.Value ?? []; @@ -40,6 +45,8 @@ async Task DeleteAsync(Guid id) } var updated = rows.Where(r => r.Id != id).ToList(); tableQuery.Mutator.Mutate(updated, revalidate: false); + refreshToken.Refresh(); + queryService.RevalidateByTag("widget-summary"); } catch (Exception ex) { @@ -66,6 +73,7 @@ async Task DeleteAsync(Guid id) .ToDataTable(r => r.Id) .Key($"widget-questions-{widgetName}") .Height(Size.Units(120)) + .RefreshToken(refreshToken) .Header(r => r.Difficulty, "Difficulty") .Header(r => r.Category, "Category") .Header(r => r.QuestionText, "Question") From e5255a8535cdbd7ea3d6f04fe5ee1c507dcfac59 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 31 Mar 2026 12:46:06 +0300 Subject: [PATCH 10/39] feat: Integrate OpenAI API for question generation in QuestionsApp, enhancing the question generation process with improved documentation context and error handling --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 5 +- .../IvyAskStatistics.csproj | 1 + .../Services/QuestionGeneratorService.cs | 98 ++++++++++++++----- 3 files changed, 76 insertions(+), 28 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index b1fc5dba..0c21daf1 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -47,9 +47,10 @@ public class QuestionsApp : ViewBase generatingWidget.Set(widget.Name); try { - var baseUrl = configuration["IvyAsk:BaseUrl"] ?? IvyAskService.DefaultMcpBaseUrl; + var apiKey = configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey secret is not set. Run: dotnet user-secrets set \"OpenAI:ApiKey\" \"\""); + var baseUrl = configuration["OpenAI:BaseUrl"] ?? throw new InvalidOperationException("OpenAI:BaseUrl secret is not set. Run: dotnet user-secrets set \"OpenAI:BaseUrl\" \"\""); await QuestionGeneratorService.GenerateAndSaveAsync( - widget, factory, baseUrl, + widget, factory, apiKey, baseUrl, new Progress(msg => generatingStatus.Set(msg))); pendingRefreshFor.Set(widget.Name); diff --git a/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj index 6c636b2b..68f1f700 100644 --- a/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj +++ b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj @@ -21,6 +21,7 @@ all + diff --git a/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs index 4f656a9d..dd1fa642 100644 --- a/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs +++ b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs @@ -1,83 +1,129 @@ using IvyAskStatistics.Connections; using Microsoft.EntityFrameworkCore; +using OpenAI; +using OpenAI.Chat; +using System.ClientModel; namespace IvyAskStatistics.Services; /// -/// Generates test questions via the same Ivy Ask HTTP API as the runner -/// (GET {base}/questions?question=... on mcp.ivy.app). -/// Uses meta-prompts that ask for a JSON array of question strings; Ivy Ask answers from docs + model. -/// Does not embed full widget markdown in the URL (GET length limits). +/// Generates test questions by fetching the full widget Markdown documentation +/// from mcp.ivy.app and passing it to OpenAI as context. +/// Uses OpenAI:ApiKey and OpenAI:BaseUrl from configuration / user secrets. /// public static class QuestionGeneratorService { /// /// Generates 10 questions per difficulty (easy / medium / hard) for a widget - /// and saves them to the database, replacing any previously MCP-generated ones. + /// and saves them to the database, replacing any previously generated ones. /// public static async Task GenerateAndSaveAsync( IvyWidget widget, AppDbContextFactory factory, - string askBaseUrl, + string openAiApiKey, + string openAiBaseUrl, IProgress? progress = null, CancellationToken ct = default) { + progress?.Report($"Fetching docs for {widget.Name}…"); + + var markdown = await FetchDocsMarkdownAsync(widget, ct); + + var chatClient = BuildChatClient(openAiApiKey, openAiBaseUrl); + foreach (var difficulty in new[] { "easy", "medium", "hard" }) { - progress?.Report($"Ivy Ask: {difficulty} questions for {widget.Name}…"); + progress?.Report($"OpenAI: generating {difficulty} questions for {widget.Name}…"); - var prompt = BuildMetaPrompt(widget.Name, widget.Category, difficulty); - var body = await IvyAskService.FetchAnswerBodyAsync(askBaseUrl, prompt, ct) - ?? throw new InvalidOperationException("Ivy Ask returned no body (check network and base URL)."); + var messages = BuildMessages(widget, difficulty, markdown); + var response = await chatClient.CompleteChatAsync(messages, cancellationToken: ct); + var body = response.Value.Content[0].Text ?? ""; var questions = IvyAskService.ParseQuestionStringsFromBody(body, 10); if (questions.Count == 0) throw new InvalidOperationException( - "Could not parse questions from Ivy Ask response. Try again or inspect logs."); + $"Could not parse {difficulty} questions from OpenAI response. Body: {body[..Math.Min(200, body.Length)]}"); await using var ctx = factory.CreateDbContext(); var existing = await ctx.Questions - .Where(q => q.Widget == widget.Name && q.Difficulty == difficulty && q.Source == "ivy_ask_meta") + .Where(q => q.Widget == widget.Name && q.Difficulty == difficulty && q.Source == "openai_docs") .ToListAsync(ct); ctx.Questions.RemoveRange(existing); ctx.Questions.AddRange(questions.Select(q => new QuestionEntity { - Widget = widget.Name, - Category = widget.Category, + Widget = widget.Name, + Category = widget.Category, Difficulty = difficulty, QuestionText = q, - Source = "ivy_ask_meta", - CreatedAt = DateTime.UtcNow + Source = "openai_docs", + CreatedAt = DateTime.UtcNow })); await ctx.SaveChangesAsync(ct); } } - private static string BuildMetaPrompt(string widgetName, string category, string difficulty) + private static async Task FetchDocsMarkdownAsync(IvyWidget widget, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(widget.DocLink)) + return $"No documentation link available for widget \"{widget.Name}\"."; + + try + { + return await IvyAskService.GetWidgetDocsAsync(widget.DocLink); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to fetch docs for \"{widget.Name}\" (link: {widget.DocLink}): {ex.Message}", ex); + } + } + + private static ChatClient BuildChatClient(string apiKey, string baseUrl) + { + var options = new OpenAIClientOptions + { + Endpoint = new Uri(baseUrl.TrimEnd('/')) + }; + var client = new OpenAIClient(new ApiKeyCredential(apiKey), options); + return client.GetChatClient("gemini-3.1-flash-lite"); + } + + private static List BuildMessages(IvyWidget widget, string difficulty, string markdown) { var difficultyHint = difficulty switch { - "easy" => "basic usage, creating the widget, simple properties", + "easy" => "basic usage, creating the widget, simple properties", "medium" => "events, styling, configuration, common patterns", - _ => "advanced composition, edge cases, integration with other Ivy widgets" + _ => "advanced composition, edge cases, integration with other Ivy widgets" }; - return $""" - You are generating automated test questions for the Ivy UI framework documentation system. + var system = new SystemChatMessage(""" + You are generating automated test questions for the Ivy UI framework documentation QA system. + You will receive the full Markdown documentation for a specific Ivy widget. + Base your questions ONLY on what is documented in the provided content. + Do not invent APIs, properties, or behaviors not mentioned in the documentation. + """); - Widget name: {widgetName} - Widget category (folder): {category} + var user = new UserChatMessage($""" + Widget name: {widget.Name} + Widget category: {widget.Category} Difficulty: {difficulty} — {difficultyHint} - Generate exactly 10 distinct questions that a C# developer might ask about this Ivy widget. - Each question must be a single sentence, under 25 words, about the "{widgetName}" widget only. + --- DOCUMENTATION --- + {markdown} + --- END DOCUMENTATION --- + + Generate exactly 10 distinct {difficulty}-level questions that a C# developer might ask about the "{widget.Name}" widget. + Each question must be a single sentence, under 25 words, and grounded in the documentation above. Do not combine multiple unrelated topics in one question. Respond with ONLY a JSON array of 10 strings (no markdown fences, no keys, no commentary). Example shape: ["question1?", "question2?", ...] - """; + """); + + return [system, user]; } } From be7c3eb437f2ee6b584905a10256d174898a8d63 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 31 Mar 2026 13:34:30 +0300 Subject: [PATCH 11/39] refactor: Update QuestionsApp to improve state management for widget generation and deletion, streamline asynchronous operations, and enhance user feedback with toast notifications --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 142 ++++++++---------- 1 file changed, 63 insertions(+), 79 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 0c21daf1..bf88e84d 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; + namespace IvyAskStatistics.Apps; internal sealed record WidgetTableData(List Rows, List Catalog, int QueryKey); @@ -13,11 +15,8 @@ public class QuestionsApp : ViewBase var configuration = UseService(); var client = UseService(); - var generateRequest = UseState(null); - var generatingWidget = UseState(""); - var generatingStatus = UseState(""); - var pendingRefreshFor = UseState(""); // widget whose table row is reloading after generation - var deleteRequest = UseState(null); // widget name to delete questions for (UseEffect runs DB work) + var generatingWidgets = UseState(ImmutableHashSet.Empty); + var deleteRequest = UseState(null); var viewDialogOpen = UseState(false); var viewDialogWidget = UseState(""); var editSheetOpen = UseState(false); @@ -36,86 +35,76 @@ public class QuestionsApp : ViewBase options: new QueryOptions { KeepPrevious = true }, tags: ["widget-summary"]); - UseEffect(() => { refreshToken.Refresh(); }, - [generatingWidget.ToTrigger(), generatingStatus.ToTrigger()]); - UseEffect(async () => { - var widget = generateRequest.Value; - if (widget == null) return; + var widgetName = deleteRequest.Value; + if (string.IsNullOrEmpty(widgetName)) return; - generatingWidget.Set(widget.Name); try { - var apiKey = configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey secret is not set. Run: dotnet user-secrets set \"OpenAI:ApiKey\" \"\""); - var baseUrl = configuration["OpenAI:BaseUrl"] ?? throw new InvalidOperationException("OpenAI:BaseUrl secret is not set. Run: dotnet user-secrets set \"OpenAI:BaseUrl\" \"\""); - await QuestionGeneratorService.GenerateAndSaveAsync( - widget, factory, apiKey, baseUrl, - new Progress(msg => generatingStatus.Set(msg))); - - pendingRefreshFor.Set(widget.Name); - refreshToken.Refresh(); + await using var ctx = factory.CreateDbContext(); + var list = await ctx.Questions.Where(q => q.Widget == widgetName).ToListAsync(); + if (list.Count == 0) + { + client.Toast("No questions to delete."); + return; + } + ctx.Questions.RemoveRange(list); + await ctx.SaveChangesAsync(); var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); tableQuery.Mutator.Mutate(fresh, revalidate: false); - pendingRefreshFor.Set(""); refreshToken.Refresh(); - client.Toast($"Generated 30 questions for {widget.Name}"); + client.Toast($"Deleted {list.Count} question(s) for \"{widgetName}\"."); } catch (Exception ex) { client.Toast($"Error: {ex.Message}"); - pendingRefreshFor.Set(""); } finally { - generatingWidget.Set(""); - generatingStatus.Set(""); - generateRequest.Set(null); + deleteRequest.Set(null); } - }, [generateRequest.ToTrigger()]); + }, [deleteRequest.ToTrigger()]); - UseEffect(async () => + async Task GenerateWidgetAsync(IvyWidget widget) { - var widgetName = deleteRequest.Value; - if (string.IsNullOrEmpty(widgetName)) return; - try { - await using var ctx = factory.CreateDbContext(); - var list = await ctx.Questions.Where(q => q.Widget == widgetName).ToListAsync(); - if (list.Count == 0) - { - client.Toast("No questions to delete."); - return; - } - ctx.Questions.RemoveRange(list); - await ctx.SaveChangesAsync(); + var apiKey = configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey secret is not set."); + var baseUrl = configuration["OpenAI:BaseUrl"] ?? throw new InvalidOperationException("OpenAI:BaseUrl secret is not set."); + await QuestionGeneratorService.GenerateAndSaveAsync(widget, factory, apiKey, baseUrl); var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); tableQuery.Mutator.Mutate(fresh, revalidate: false); refreshToken.Refresh(); - client.Toast($"Deleted {list.Count} question(s) for \"{widgetName}\"."); + client.Toast($"Generated 30 questions for {widget.Name}"); } catch (Exception ex) { - client.Toast($"Error: {ex.Message}"); + client.Toast($"Error ({widget.Name}): {ex.Message}"); } finally { - deleteRequest.Set(null); + generatingWidgets.Set(s => s.Remove(widget.Name)); + refreshToken.Refresh(); } - }, [deleteRequest.ToTrigger()]); + } - // ── Derived state ───────────────────────────────────────────────────── - var baseRows = tableQuery.Value?.Rows ?? []; - var catalog = tableQuery.Value?.Catalog ?? []; - var generating = generatingWidget.Value; - var isGenerating = !string.IsNullOrEmpty(generating); - var isDeleting = !string.IsNullOrEmpty(deleteRequest.Value); - // Full-screen loader only before the first successful query payload (KeepPrevious keeps rows during Mutate). - var firstLoad = tableQuery.Loading && tableQuery.Value == null; - var pendingRow = pendingRefreshFor.Value; + void MarkGenerating(IEnumerable widgetNames) + { + var names = widgetNames.Where(n => !string.IsNullOrEmpty(n)).ToHashSet(); + if (names.Count == 0) return; + + generatingWidgets.Set(s => s.Union(names).ToImmutableHashSet()); + refreshToken.Refresh(); + } + + var generating = generatingWidgets.Value; + var baseRows = tableQuery.Value?.Rows ?? []; + var catalog = tableQuery.Value?.Catalog ?? []; + var isDeleting = !string.IsNullOrEmpty(deleteRequest.Value); + var firstLoad = tableQuery.Loading && tableQuery.Value == null; static string IdleStatus(WidgetRow r) { @@ -123,19 +112,11 @@ static string IdleStatus(WidgetRow r) return n == 0 ? "○ Not generated" : "✓ Generated"; } - // Status per row: generating / updating DB / idle var rows = baseRows.Select(r => - { - if (r.Widget == generating) - { - return r with { Status = $"Generating…" }; - } - - if (r.Widget == pendingRow) - return r with { Status = "Updating…" }; - - return r with { Status = IdleStatus(r) }; - }).ToList(); + generating.Contains(r.Widget) + ? r with { Status = "Generating…" } + : r with { Status = IdleStatus(r) } + ).ToList(); if (firstLoad) return Layout.Center() @@ -145,7 +126,6 @@ static string IdleStatus(WidgetRow r) var table = rows.AsQueryable() .ToDataTable(r => r.Widget) .RefreshToken(refreshToken) - // Stable key: Mutator.Mutate + RefreshToken update rows; changing Key remounted the grid and made it disappear on delete. .Key("questions-widgets") .Height(Size.Full()) .Header(r => r.Widget, "Widget") @@ -188,23 +168,24 @@ static string IdleStatus(WidgetRow r) client.Toast("Please wait — delete in progress"); return ValueTask.CompletedTask; } - if (isGenerating) + + var genName = args.Id?.ToString() ?? ""; + if (generating.Contains(genName)) { - client.Toast("Already generating — please wait"); + client.Toast($"\"{genName}\" is already being generated"); return ValueTask.CompletedTask; } - var genName = args.Id?.ToString() ?? ""; + var widget = catalog.FirstOrDefault(w => w.Name == genName) ?? new IvyWidget(genName, rows.FirstOrDefault(r => r.Widget == genName)?.Category ?? "", ""); + showAlert( - $"Generate 30 questions for the \"{widget.Name}\" widget?\n\nIvy Ask will be called three times (easy / medium / hard). Any previously generated questions for this widget will be replaced.", + $"Generate 30 questions for the \"{widget.Name}\" widget?\n\nOpenAI will be called three times (easy / medium / hard). Any previously generated questions for this widget will be replaced.", result => { if (!result.IsOk()) return; - generatingWidget.Set(widget.Name); - generatingStatus.Set("Starting…"); - generateRequest.Set(widget); - refreshToken.Refresh(); + MarkGenerating([widget.Name]); + _ = GenerateWidgetAsync(widget); }, "Generate questions", AlertButtonSet.OkCancel); @@ -218,19 +199,22 @@ static string IdleStatus(WidgetRow r) client.Toast("Delete already in progress — please wait"); return ValueTask.CompletedTask; } - if (isGenerating) + + var delName = args.Id?.ToString() ?? ""; + if (generating.Contains(delName)) { - client.Toast("Already generating — please wait"); + client.Toast($"\"{delName}\" is currently being generated — please wait"); return ValueTask.CompletedTask; } - var delName = args.Id?.ToString() ?? ""; - var row = rows.FirstOrDefault(r => r.Widget == delName); - var n = row == null ? 0 : row.Easy + row.Medium + row.Hard; + + var row = rows.FirstOrDefault(r => r.Widget == delName); + var n = row == null ? 0 : row.Easy + row.Medium + row.Hard; if (n == 0) { client.Toast("No questions to delete for this widget."); return ValueTask.CompletedTask; } + showAlert( $"Delete all {n} question(s) for the \"{delName}\" widget?\n\nThis cannot be undone.", result => @@ -302,7 +286,7 @@ private static async Task LoadWidgetTableDataAsync( List catalog = []; try { catalog = await IvyAskService.GetWidgetsAsync(); } - catch { /* offline */ } + catch { } var byName = catalog.ToDictionary(w => w.Name); foreach (var (widget, info) in countsByWidget) From bda069c7da739938b942c72c10600cf8c5f41825 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 31 Mar 2026 18:00:13 +0300 Subject: [PATCH 12/39] refactor: Rename RunApp title for clarity, enhance state management with refresh token integration, and improve question display with new status icons and time tracking --- .../ivy-ask-statistics/Apps/RunApp.cs | 87 ++++++++++--------- .../ivy-ask-statistics/Models/RunModels.cs | 4 +- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index c1fc3c7e..ed0fe855 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -1,6 +1,6 @@ namespace IvyAskStatistics.Apps; -[App(icon: Icons.ChartBar, title: "IVY Ask Statistics")] +[App(icon: Icons.ChartBar, title: "Run Tests")] public class RunApp : ViewBase { private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; @@ -11,12 +11,11 @@ public class RunApp : ViewBase var factory = UseService(); var client = UseService(); - // ── State ───────────────────────────────────────────────────────────── var difficultyFilter = UseState("all"); var runningIndex = UseState(-1); var completed = UseState>([]); + var refreshToken = UseRefreshToken(); - // ── Hooks (must be at top, before derived values) ───────────────────── UseEffect(() => { completed.Set([]); @@ -33,6 +32,7 @@ public class RunApp : ViewBase if (idx >= questions.Count) { runningIndex.Set(-1); + refreshToken.Refresh(); var s = completed.Value.Count(r => r.Status == "success"); client.Toast($"Done! {s}/{questions.Count} answered"); return; @@ -40,10 +40,10 @@ public class RunApp : ViewBase var result = await IvyAskService.AskAsync(questions[idx], BaseUrl); completed.Set([.. completed.Value, result]); + refreshToken.Refresh(); runningIndex.Set(idx + 1); }, [runningIndex.ToTrigger()]); - // ── Queries ─────────────────────────────────────────────────────────── var questionsQuery = UseQuery, string>( key: $"questions-{difficultyFilter.Value}", fetcher: async (_, ct) => await LoadQuestionsAsync(factory, difficultyFilter.Value)); @@ -51,7 +51,6 @@ public class RunApp : ViewBase var questions = questionsQuery.Value ?? []; var isRunning = runningIndex.Value >= 0; - // ── Derived values ──────────────────────────────────────────────────── var done = completed.Value.Count; var success = completed.Value.Count(r => r.Status == "success"); var noAnswer = completed.Value.Count(r => r.Status == "no_answer"); @@ -59,20 +58,36 @@ public class RunApp : ViewBase var avgMs = done > 0 ? (int)completed.Value.Average(r => r.ResponseTimeMs) : 0; var progressPct = questions.Count > 0 ? done * 100 / questions.Count : 0; - // ── Build display rows ──────────────────────────────────────────────── var rows = questions.Select((q, i) => { var r = completed.Value.FirstOrDefault(x => x.Question.Id == q.Id); - var status = r?.Status ?? (i == runningIndex.Value ? "running" : "pending"); - return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, status, r?.ResponseTimeMs); + Icons icon; + string status, time; + if (r != null) + { + icon = r.Status == "success" ? Icons.CircleCheck : Icons.CircleX; + status = ToStatusLabel(r.Status); + time = $"{r.ResponseTimeMs}ms"; + } + else if (i == runningIndex.Value) + { + icon = Icons.Loader; + status = "in progress"; + time = ""; + } + else + { + icon = Icons.Clock; + status = "pending"; + time = ""; + } + return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, icon, status, time); }).ToList(); - // ── Controls bar ────────────────────────────────────────────────────── - var controls = new Card( - Layout.Horizontal() + var controls = Layout.Horizontal().Height(Size.Fit()) | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(isRunning) | Text.Muted($"{questions.Count} questions") - | new Spacer() + | (isRunning ? new Progress(progressPct).Goal($"{done}/{questions.Count}") : null) | new Button(isRunning ? "Running…" : "Run All", onClick: _ => { @@ -81,31 +96,26 @@ public class RunApp : ViewBase }) .Primary() .Icon(isRunning ? Icons.Loader : Icons.Play) - .Disabled(isRunning || questionsQuery.Loading || questions.Count == 0) - ); - - // ── Progress bar ────────────────────────────────────────────────────── - object? progressSection = isRunning || done > 0 - ? new Progress(progressPct).Goal($"{done} / {questions.Count} questions") - : null; + .Disabled(isRunning || questionsQuery.Loading || questions.Count == 0); - // ── Summary card (only after run completes) ─────────────────────────── - object? summarySection = done > 0 && !isRunning - ? BuildSummary(success, noAnswer, errors, done, avgMs) - : null; - - // ── Questions DataTable ─────────────────────────────────────────────── var table = rows.AsQueryable() .ToDataTable() + .RefreshToken(refreshToken) + .Key("run-tests-table") + .Height(Size.Full()) .Hidden(r => r.Id) - .Hidden(r => r.ResponseTimeMs) .Header(r => r.Widget, "Widget") .Header(r => r.Difficulty, "Difficulty") .Header(r => r.Question, "Question") + .Header(r => r.ResultIcon, "Icon") .Header(r => r.Status, "Status") + .Header(r => r.Time, "Time") + .Width(r => r.ResultIcon, Size.Px(50)) .Width(r => r.Widget, Size.Px(120)) - .Width(r => r.Difficulty, Size.Px(100)) - .Width(r => r.Status, Size.Px(120)) + .Width(r => r.Difficulty, Size.Px(80)) + .Width(r => r.Status, Size.Px(100)) + .Width(r => r.Time, Size.Px(80)) + .Width(r => r.Question, Size.Px(400)) .Config(config => { config.AllowSorting = true; @@ -114,10 +124,8 @@ public class RunApp : ViewBase config.ShowIndexColumn = true; }); - return Layout.Vertical() + return Layout.Vertical().Height(Size.Full()) | controls - | progressSection - | summarySection | table; } @@ -140,16 +148,11 @@ private static async Task> LoadQuestionsAsync( .ToList(); } - private static object BuildSummary(int success, int noAnswer, int errors, int total, int avgMs) + private static string ToStatusLabel(string status) => status switch { - var rate = total > 0 ? success * 100 / total : 0; - - return new Card( - Layout.Horizontal() - | new Details([new Detail("Success Rate", $"{rate}% ({success}/{total})", false)]) - | new Details([new Detail("No Answer", noAnswer.ToString(), false)]) - | new Details([new Detail("Errors", errors.ToString(), false)]) - | new Details([new Detail("Avg Time", $"{avgMs} ms", false)]) - ); - } + "success" => "answered", + "no_answer" => "no answer", + "error" => "error", + _ => status.Replace('_', ' ') + }; } diff --git a/project-demos/ivy-ask-statistics/Models/RunModels.cs b/project-demos/ivy-ask-statistics/Models/RunModels.cs index 020f335c..c72bc45c 100644 --- a/project-demos/ivy-ask-statistics/Models/RunModels.cs +++ b/project-demos/ivy-ask-statistics/Models/RunModels.cs @@ -9,12 +9,12 @@ public record QuestionRun( int HttpStatus ); -// Flat row for the TableBuilder in RunApp public record QuestionRow( string Id, string Widget, string Difficulty, string Question, + Icons ResultIcon, string Status, - int? ResponseTimeMs + string Time ); From 3099a4764b9659d3109331b630696054c371492f Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 31 Mar 2026 21:44:20 +0300 Subject: [PATCH 13/39] feat: Add DashboardApp for displaying comprehensive statistics with KPI cards, charts, and detailed tables, enhancing data visualization in the Ivy Ask Statistics project --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 222 ++++++++++++++++++ .../ivy-ask-statistics/Apps/RunApp.cs | 38 ++- .../Connections/AppDbContextFactory.cs | 5 + .../Connections/QuestionEntity.cs | 8 + .../ivy-ask-statistics/Models/RunModels.cs | 5 +- .../Services/IvyAskService.cs | 3 +- 6 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 project-demos/ivy-ask-statistics/Apps/DashboardApp.cs diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs new file mode 100644 index 00000000..7eb49ff7 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -0,0 +1,222 @@ +namespace IvyAskStatistics.Apps; + +[App(icon: Icons.LayoutDashboard, title: "Dashboard")] +public class DashboardApp : ViewBase +{ + private const float ContentWidth = 0.95f; + + public override object? Build() + { + var factory = UseService(); + + var statsQuery = UseQuery( + key: 0, + fetcher: async (_, ct) => await LoadStatsAsync(factory, ct), + options: new QueryOptions { KeepPrevious = true }); + + var stats = statsQuery.Value; + + if (statsQuery.Loading && stats == null) + return Layout.Center() + | new Icon(Icons.Loader) + | Text.Muted("Loading dashboard…"); + + if (stats == null) + return Layout.Center() | Text.Muted("No data yet. Run some tests first."); + + // ── KPI cards ──────────────────────────────────────────────────────── + var kpiRow = Layout.Grid().Columns(4).Gap(3).Width(Size.Fraction(ContentWidth)) + | new Card( + Layout.Vertical().Gap(2).Padding(3) + | Text.H3(stats.TotalRuns.ToString("N0")) + | Text.Block("questions tested across all runs").Muted() + ).Title("Total Runs").Icon(Icons.Play) + | new Card( + Layout.Vertical().Gap(2).Padding(3) + | Text.H3(stats.AnswerRate.ToString("F1") + "%") + | Text.Block($"{stats.Answered} answered / {stats.NoAnswer} no answer / {stats.Errors} errors").Muted() + ).Title("Answer Rate").Icon(Icons.CircleCheck) + | new Card( + Layout.Vertical().Gap(2).Padding(3) + | Text.H3(stats.AvgResponseMs + " ms") + | Text.Block($"fastest {stats.MinResponseMs} ms · slowest {stats.MaxResponseMs} ms").Muted() + ).Title("Avg Response Time").Icon(Icons.Timer) + | new Card( + Layout.Vertical().Gap(2).Padding(3) + | Text.H3(stats.WorstWidget) + | Text.Block($"{stats.WorstWidgetRate:F1}% answer rate · most unanswered").Muted() + ).Title("Weakest Widget").Icon(Icons.CircleX); + + // ── Pie chart: overall distribution ────────────────────────────────── + var distributionData = new[] + { + new { Label = "Answered", Count = stats.Answered }, + new { Label = "No answer", Count = stats.NoAnswer }, + new { Label = "Error", Count = stats.Errors } + }.Where(x => x.Count > 0).ToList(); + + var pieChart = distributionData.ToPieChart( + dimension: x => x.Label, + measure: x => x.Sum(f => f.Count), + PieChartStyles.Dashboard, + new PieChartTotal(stats.TotalRuns.ToString("N0"), "Total")); + + // ── Bar chart: answer rate per widget (worst first) ─────────────────── + var answerRateData = stats.WidgetStats + .OrderBy(w => w.AnswerRate) + .ToList(); + + var answerRateChart = answerRateData.ToBarChart() + .Dimension("Widget", x => x.Widget) + .Measure("Answer rate %", x => x.Sum(f => f.AnswerRate)); + + // ── Bar chart: avg response time per widget (slowest first) ─────────── + var responseTimeData = stats.WidgetStats + .OrderByDescending(w => w.AvgMs) + .ToList(); + + var responseTimeChart = responseTimeData.ToBarChart() + .Dimension("Widget", x => x.Widget) + .Measure("Avg ms", x => x.Sum(f => f.AvgMs)); + + // ── Bar chart: results by difficulty ───────────────────────────────── + var diffData = stats.DifficultyStats; + + var diffChart = diffData.ToBarChart() + .Dimension("Difficulty", x => x.Difficulty) + .Measure("Answered", x => x.Sum(f => f.Answered)) + .Measure("No answer", x => x.Sum(f => f.NoAnswer)) + .Measure("Error", x => x.Sum(f => f.Errors)); + + // ── Detail table ────────────────────────────────────────────────────── + var tableRows = stats.WidgetStats + .OrderBy(w => w.AnswerRate) + .AsQueryable() + .ToDataTable(r => r.Widget) + .Key("dashboard-widget-table") + .Header(r => r.Widget, "Widget") + .Header(r => r.TotalRuns, "Runs") + .Header(r => r.Answered, "Answered") + .Header(r => r.NoAnswer, "No Answer") + .Header(r => r.Errors, "Errors") + .Header(r => r.AnswerRate, "Answer Rate %") + .Header(r => r.AvgMs, "Avg ms") + .Width(r => r.Widget, Size.Px(160)) + .Width(r => r.TotalRuns, Size.Px(70)) + .Width(r => r.Answered, Size.Px(90)) + .Width(r => r.NoAnswer, Size.Px(100)) + .Width(r => r.Errors, Size.Px(70)) + .Width(r => r.AnswerRate, Size.Px(120)) + .Width(r => r.AvgMs, Size.Px(80)) + .Config(c => + { + c.AllowSorting = true; + c.AllowFiltering = true; + c.ShowSearch = true; + }); + + return Layout.Vertical().Gap(4).Padding(4).Align(Align.TopCenter).Height(Size.Full()) + | kpiRow + | (Layout.Grid().Columns(2).Gap(3).Width(Size.Fraction(ContentWidth)) + | new Card(pieChart).Title("Result Distribution") + | new Card(diffChart).Title("Results by Difficulty")) + | (Layout.Grid().Columns(2).Gap(3).Width(Size.Fraction(ContentWidth)) + | new Card(answerRateChart).Title("Answer Rate by Widget (worst first)") + | new Card(responseTimeChart).Title("Avg Response Time by Widget (slowest first)")) + | (Layout.Vertical().Gap(2).Width(Size.Fraction(ContentWidth)) + | new Card(tableRows).Title("Per-Widget Breakdown")); + } + + private static async Task LoadStatsAsync(AppDbContextFactory factory, CancellationToken ct) + { + await using var ctx = factory.CreateDbContext(); + + var results = await ctx.Questions + .AsNoTracking() + .Where(q => q.LastRunStatus != null) + .ToListAsync(ct); + + if (results.Count == 0) return null; + + var totalRuns = results.Count; + var answered = results.Count(r => r.LastRunStatus == "success"); + var noAnswer = results.Count(r => r.LastRunStatus == "no_answer"); + var errors = results.Count(r => r.LastRunStatus == "error"); + var answerRate = totalRuns > 0 ? answered * 100.0 / totalRuns : 0; + var avgMs = (int)results.Average(r => r.LastRunResponseTimeMs ?? 0); + var minMs = results.Min(r => r.LastRunResponseTimeMs ?? 0); + var maxMs = results.Max(r => r.LastRunResponseTimeMs ?? 0); + + var widgetStats = results + .GroupBy(r => r.Widget) + .Select(g => + { + var total = g.Count(); + var ans = g.Count(r => r.LastRunStatus == "success"); + var noAns = g.Count(r => r.LastRunStatus == "no_answer"); + var err = g.Count(r => r.LastRunStatus == "error"); + var rate = total > 0 ? Math.Round(ans * 100.0 / total, 1) : 0; + var avg = (int)g.Average(r => r.LastRunResponseTimeMs ?? 0); + return new WidgetStatRow(g.Key, total, ans, noAns, err, rate, avg); + }) + .OrderBy(w => w.Widget) + .ToList(); + + var worstWidget = widgetStats.MinBy(w => w.AnswerRate); + + var diffStats = results + .GroupBy(r => r.Difficulty) + .Select(g => + { + var ans = g.Count(r => r.LastRunStatus == "success"); + var noAns = g.Count(r => r.LastRunStatus == "no_answer"); + var err = g.Count(r => r.LastRunStatus == "error"); + return new DifficultyStatRow(g.Key, ans, noAns, err); + }) + .OrderBy(d => d.Difficulty == "easy" ? 0 : d.Difficulty == "medium" ? 1 : 2) + .ToList(); + + return new DashboardStats( + TotalRuns: totalRuns, + Answered: answered, + NoAnswer: noAnswer, + Errors: errors, + AnswerRate: Math.Round(answerRate, 1), + AvgResponseMs: avgMs, + MinResponseMs: minMs, + MaxResponseMs: maxMs, + WorstWidget: worstWidget?.Widget ?? "—", + WorstWidgetRate: worstWidget?.AnswerRate ?? 0, + WidgetStats: widgetStats, + DifficultyStats: diffStats); + } +} + +internal record DashboardStats( + int TotalRuns, + int Answered, + int NoAnswer, + int Errors, + double AnswerRate, + int AvgResponseMs, + int MinResponseMs, + int MaxResponseMs, + string WorstWidget, + double WorstWidgetRate, + List WidgetStats, + List DifficultyStats); + +internal record WidgetStatRow( + string Widget, + int TotalRuns, + int Answered, + int NoAnswer, + int Errors, + double AnswerRate, + int AvgMs); + +internal record DifficultyStatRow( + string Difficulty, + int Answered, + int NoAnswer, + int Errors); diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index ed0fe855..1ba52cd2 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -14,12 +14,14 @@ public class RunApp : ViewBase var difficultyFilter = UseState("all"); var runningIndex = UseState(-1); var completed = UseState>([]); + var runQueue = UseState>([]); var refreshToken = UseRefreshToken(); UseEffect(() => { completed.Set([]); runningIndex.Set(-1); + runQueue.Set([]); }, [difficultyFilter.ToTrigger()]); UseEffect(async () => @@ -27,19 +29,25 @@ public class RunApp : ViewBase var idx = runningIndex.Value; if (idx < 0) return; - var questions = await LoadQuestionsAsync(factory, difficultyFilter.Value); + var questions = runQueue.Value; if (idx >= questions.Count) { runningIndex.Set(-1); + runQueue.Set([]); refreshToken.Refresh(); var s = completed.Value.Count(r => r.Status == "success"); client.Toast($"Done! {s}/{questions.Count} answered"); return; } + // Let the table paint the current row as "in progress" before awaiting the HTTP call. + refreshToken.Refresh(); + await Task.Yield(); + var result = await IvyAskService.AskAsync(questions[idx], BaseUrl); completed.Set([.. completed.Value, result]); + _ = SaveResultAsync(factory, result); refreshToken.Refresh(); runningIndex.Set(idx + 1); }, [runningIndex.ToTrigger()]); @@ -48,8 +56,8 @@ public class RunApp : ViewBase key: $"questions-{difficultyFilter.Value}", fetcher: async (_, ct) => await LoadQuestionsAsync(factory, difficultyFilter.Value)); - var questions = questionsQuery.Value ?? []; var isRunning = runningIndex.Value >= 0; + var questions = isRunning && runQueue.Value.Count > 0 ? runQueue.Value : questionsQuery.Value ?? []; var done = completed.Value.Count; var success = completed.Value.Count(r => r.Status == "success"); @@ -91,7 +99,10 @@ public class RunApp : ViewBase | new Button(isRunning ? "Running…" : "Run All", onClick: _ => { + var snapshot = questionsQuery.Value ?? []; + if (snapshot.Count == 0) return; completed.Set([]); + runQueue.Set(snapshot); runningIndex.Set(0); }) .Primary() @@ -148,6 +159,29 @@ private static async Task> LoadQuestionsAsync( .ToList(); } + private static async Task SaveResultAsync(AppDbContextFactory factory, QuestionRun result) + { + try + { + if (!Guid.TryParse(result.Question.Id, out var questionId)) return; + await using var ctx = factory.CreateDbContext(); + var entity = await ctx.Questions.FindAsync(questionId); + if (entity == null) return; + + entity.LastRunStatus = result.Status; + entity.LastRunResponseTimeMs = result.ResponseTimeMs; + entity.LastRunHttpStatus = result.HttpStatus; + entity.LastRunAnswerText = result.AnswerText; + entity.LastRunAt = DateTime.UtcNow; + + await ctx.SaveChangesAsync(); + } + catch + { + // silently ignore — run results are best-effort + } + } + private static string ToStatusLabel(string status) => status switch { "success" => "answered", diff --git a/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs index 2d38713d..3f33a92e 100644 --- a/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs +++ b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs @@ -47,6 +47,11 @@ CREATE TABLE IF NOT EXISTS ivy_ask_questions ( "Source" VARCHAR(20) NOT NULL DEFAULT 'manual', "CreatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunStatus" VARCHAR(20); + ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunResponseTimeMs" INTEGER; + ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunHttpStatus" INTEGER; + ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunAnswerText" TEXT; + ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunAt" TIMESTAMPTZ; """); _initialized = true; } diff --git a/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs b/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs index 4f25a92e..1a486709 100644 --- a/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs +++ b/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs @@ -23,4 +23,12 @@ public class QuestionEntity public string Source { get; set; } = "manual"; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Last run result + [MaxLength(20)] + public string? LastRunStatus { get; set; } + public int? LastRunResponseTimeMs { get; set; } + public int? LastRunHttpStatus { get; set; } + public string? LastRunAnswerText { get; set; } + public DateTime? LastRunAt { get; set; } } diff --git a/project-demos/ivy-ask-statistics/Models/RunModels.cs b/project-demos/ivy-ask-statistics/Models/RunModels.cs index c72bc45c..97784299 100644 --- a/project-demos/ivy-ask-statistics/Models/RunModels.cs +++ b/project-demos/ivy-ask-statistics/Models/RunModels.cs @@ -4,9 +4,10 @@ public record TestQuestion(string Id, string Widget, string Difficulty, string Q public record QuestionRun( TestQuestion Question, - string Status, // "success" | "no_answer" | "error" + string Status, // "success" | "no_answer" | "error" int ResponseTimeMs, - int HttpStatus + int HttpStatus, + string AnswerText = "" // raw response body; empty when no_answer or error ); public record QuestionRow( diff --git a/project-demos/ivy-ask-statistics/Services/IvyAskService.cs b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs index 41ed1b68..54c85278 100644 --- a/project-demos/ivy-ask-statistics/Services/IvyAskService.cs +++ b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs @@ -42,7 +42,8 @@ public static async Task AskAsync(TestQuestion question, string bas _ => "error" }; - return new QuestionRun(question, status, ms, httpStatus); + var answerText = status == "success" ? body : ""; + return new QuestionRun(question, status, ms, httpStatus, answerText); } catch { From 3f9602aae049cdd5e9d8ee65d5b39b525c6cc196 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 31 Mar 2026 22:00:22 +0300 Subject: [PATCH 14/39] refactor: Enhance RunApp by integrating refresh token management during question loading and streamline AppDbContextFactory for improved database context initialization --- .../ivy-ask-statistics/Apps/RunApp.cs | 7 ++++- .../Connections/AppDbContextFactory.cs | 29 +++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index 1ba52cd2..25b1166f 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -54,7 +54,12 @@ public class RunApp : ViewBase var questionsQuery = UseQuery, string>( key: $"questions-{difficultyFilter.Value}", - fetcher: async (_, ct) => await LoadQuestionsAsync(factory, difficultyFilter.Value)); + fetcher: async (_, ct) => + { + var result = await LoadQuestionsAsync(factory, difficultyFilter.Value); + refreshToken.Refresh(); + return result; + }); var isRunning = runningIndex.Value >= 0; var questions = isRunning && runQueue.Value.Count > 0 ? runQueue.Value : questionsQuery.Value ?? []; diff --git a/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs index 3f33a92e..c753b9bd 100644 --- a/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs +++ b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs @@ -17,24 +17,24 @@ public AppDbContextFactory(IConfiguration config) public AppDbContext CreateDbContext() { - var cs = _config["DB_CONNECTION_STRING"] - ?? throw new InvalidOperationException( - "DB_CONNECTION_STRING not set. Run: dotnet user-secrets set \"DB_CONNECTION_STRING\" \"\""); - - return new AppDbContext( - new DbContextOptionsBuilder() - .UseNpgsql(cs) - .Options); + var ctx = new AppDbContext(BuildOptions()); + EnsureInitialized(ctx); + return ctx; } private void EnsureInitialized() + { + using var ctx = new AppDbContext(BuildOptions()); + EnsureInitialized(ctx); + } + + private void EnsureInitialized(AppDbContext ctx) { if (_initialized) return; _initLock.Wait(); try { if (_initialized) return; - using var ctx = CreateDbContext(); // EnsureCreated() does nothing when the database already exists (e.g. Supabase). // Use explicit CREATE TABLE IF NOT EXISTS instead. ctx.Database.ExecuteSqlRaw(""" @@ -60,4 +60,15 @@ CREATE TABLE IF NOT EXISTS ivy_ask_questions ( _initLock.Release(); } } + + private DbContextOptions BuildOptions() + { + var cs = _config["DB_CONNECTION_STRING"] + ?? throw new InvalidOperationException( + "DB_CONNECTION_STRING not set. Run: dotnet user-secrets set \"DB_CONNECTION_STRING\" \"\""); + + return new DbContextOptionsBuilder() + .UseNpgsql(cs) + .Options; + } } From 3b37f8b54891e98ef47d755243cd16c9d5edc980 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Wed, 1 Apr 2026 10:21:16 +0300 Subject: [PATCH 15/39] feat: Implement "Generate All Questions" functionality in QuestionsApp, allowing users to generate questions for multiple widgets at once, and enhance state management with improved user feedback and error handling --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 96 +++++++++++++++++-- project-demos/ivy-ask-statistics/Program.cs | 12 ++- .../Services/GenerateAllBridge.cs | 15 +++ 3 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index bf88e84d..2ff8feae 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -21,7 +21,8 @@ public class QuestionsApp : ViewBase var viewDialogWidget = UseState(""); var editSheetOpen = UseState(false); var editQuestionId = UseState(Guid.Empty); - var refreshToken = UseRefreshToken(); + var refreshToken = UseRefreshToken(); + var generateAllPending = UseState(false); var (alertView, showAlert) = UseAlert(); var tableQuery = UseQuery( @@ -32,7 +33,7 @@ public class QuestionsApp : ViewBase refreshToken.Refresh(); return result; }, - options: new QueryOptions { KeepPrevious = true }, + options: new QueryOptions { KeepPrevious = true, RefreshInterval = TimeSpan.FromSeconds(10), RevalidateOnMount = true }, tags: ["widget-summary"]); UseEffect(async () => @@ -67,18 +68,57 @@ public class QuestionsApp : ViewBase } }, [deleteRequest.ToTrigger()]); + UseEffect(() => + { + var action = GenerateAllBridge.Consume(); + if (action == "generate-all") + generateAllPending.Set(true); + + if (!generateAllPending.Value) return; + + var allWidgets = tableQuery.Value?.Catalog ?? []; + if (allWidgets.Count == 0) return; + + generateAllPending.Set(false); + + var allRows = tableQuery.Value?.Rows ?? []; + var notGenerated = allWidgets + .Where(w => !allRows.Any(r => r.Widget == w.Name && r.Easy + r.Medium + r.Hard > 0)) + .ToList(); + + if (notGenerated.Count == 0) + { + client.Toast("All widgets already have generated questions."); + return; + } + + showAlert( + $"Generate questions for {notGenerated.Count} widget(s) that don't have questions yet?\n\nOpenAI will be called 3 times per widget (easy / medium / hard).", + result => + { + if (!result.IsOk()) return; + MarkGenerating(notGenerated.Select(w => w.Name)); + Task.Run(() => GenerateBatchAsync(notGenerated)); + }, + "Generate All Questions", + AlertButtonSet.OkCancel); + }, EffectTrigger.OnBuild()); + + async Task GenerateOneAsync(IvyWidget widget) + { + var apiKey = configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey secret is not set."); + var baseUrl = configuration["OpenAI:BaseUrl"] ?? throw new InvalidOperationException("OpenAI:BaseUrl secret is not set."); + await QuestionGeneratorService.GenerateAndSaveAsync(widget, factory, apiKey, baseUrl); + } + async Task GenerateWidgetAsync(IvyWidget widget) { try { - var apiKey = configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey secret is not set."); - var baseUrl = configuration["OpenAI:BaseUrl"] ?? throw new InvalidOperationException("OpenAI:BaseUrl secret is not set."); - await QuestionGeneratorService.GenerateAndSaveAsync(widget, factory, apiKey, baseUrl); + await GenerateOneAsync(widget); var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); tableQuery.Mutator.Mutate(fresh, revalidate: false); - refreshToken.Refresh(); - client.Toast($"Generated 30 questions for {widget.Name}"); } catch (Exception ex) { @@ -91,6 +131,47 @@ async Task GenerateWidgetAsync(IvyWidget widget) } } + async Task GenerateBatchAsync(List widgets) + { + const int maxRetries = 2; + var failed = new List(); + var done = 0; + + foreach (var widget in widgets) + { + var success = false; + for (var attempt = 1; attempt <= maxRetries && !success; attempt++) + { + try + { + client.Toast($"Generating {done + 1}/{widgets.Count}: {widget.Name}…"); + await GenerateOneAsync(widget); + success = true; + } + catch + { + if (attempt < maxRetries) + await Task.Delay(2000); + } + } + + if (success) + done++; + else + failed.Add(widget.Name); + } + + var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); + tableQuery.Mutator.Mutate(fresh, revalidate: false); + generatingWidgets.Set(_ => ImmutableHashSet.Empty); + refreshToken.Refresh(); + + if (failed.Count == 0) + client.Toast($"Done! Generated questions for all {done} widget(s)."); + else + client.Toast($"Done: {done}/{widgets.Count} succeeded. Failed: {string.Join(", ", failed)}"); + } + void MarkGenerating(IEnumerable widgetNames) { var names = widgetNames.Where(n => !string.IsNullOrEmpty(n)).ToHashSet(); @@ -123,6 +204,7 @@ static string IdleStatus(WidgetRow r) | new Icon(Icons.Loader) | Text.Muted("Loading…"); + var isGeneratingAny = generating.Count > 0; var table = rows.AsQueryable() .ToDataTable(r => r.Widget) .RefreshToken(refreshToken) diff --git a/project-demos/ivy-ask-statistics/Program.cs b/project-demos/ivy-ask-statistics/Program.cs index 5dca1226..0500cfe8 100644 --- a/project-demos/ivy-ask-statistics/Program.cs +++ b/project-demos/ivy-ask-statistics/Program.cs @@ -11,7 +11,17 @@ var appShellSettings = new AppShellSettings() .DefaultApp() - .UseTabs(preventDuplicates: true); + .UseTabs(preventDuplicates: true) + .UseFooterMenuItemsTransformer((items, navigator) => + { + var list = items.ToList(); + list.Add(MenuItem.Default("Generate All Questions").Icon(Icons.Sparkles).OnSelect(() => + { + GenerateAllBridge.Request("generate-all"); + navigator.Navigate(typeof(QuestionsApp)); + })); + return list; + }); server.UseAppShell(appShellSettings); await server.RunAsync(); diff --git a/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs b/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs new file mode 100644 index 00000000..9b9fe9a5 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs @@ -0,0 +1,15 @@ +namespace IvyAskStatistics.Apps; + +public static class GenerateAllBridge +{ + static string? _pending; + + public static void Request(string action) => _pending = action; + + public static string? Consume() + { + var val = _pending; + _pending = null; + return val; + } +} From d280b906082b5e9c7f90350252c042896ec0dd45 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Wed, 1 Apr 2026 21:21:37 +0300 Subject: [PATCH 16/39] feat: Enhance DashboardApp with version selection and improved statistics display, integrating test run management and data visualization for better user experience --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 197 +++++++++++++----- .../Apps/QuestionEditSheet.cs | 4 + .../ivy-ask-statistics/Apps/RunApp.cs | 119 +++++++++-- .../Connections/AppDbContext.cs | 31 +++ .../Connections/AppDbContextFactory.cs | 33 ++- .../Connections/QuestionEntity.cs | 10 +- .../Connections/TestRunEntity.cs | 26 +++ 7 files changed, 339 insertions(+), 81 deletions(-) create mode 100644 project-demos/ivy-ask-statistics/Connections/TestRunEntity.cs diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index 7eb49ff7..ba463078 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -9,28 +9,54 @@ public class DashboardApp : ViewBase { var factory = UseService(); - var statsQuery = UseQuery( + var selectedVersion = UseState(""); + var refreshToken = UseRefreshToken(); + + var versionsQuery = UseQuery, int>( key: 0, - fetcher: async (_, ct) => await LoadStatsAsync(factory, ct), + fetcher: async (_, ct) => await LoadRunSummariesAsync(factory, ct), options: new QueryOptions { KeepPrevious = true }); - var stats = statsQuery.Value; + var statsQuery = UseQuery( + key: selectedVersion.Value, + fetcher: async (version, ct) => + { + if (string.IsNullOrEmpty(version)) return null; + return await LoadStatsForVersionAsync(factory, version, ct); + }, + options: new QueryOptions { KeepPrevious = true }); - if (statsQuery.Loading && stats == null) + var versions = versionsQuery.Value ?? []; + + if (versionsQuery.Loading && versions.Count == 0) return Layout.Center() | new Icon(Icons.Loader) | Text.Muted("Loading dashboard…"); + if (versions.Count == 0) + return Layout.Center() | Text.Muted("No test runs yet. Run some tests first."); + + if (string.IsNullOrEmpty(selectedVersion.Value) && versions.Count > 0) + selectedVersion.Set(versions[0].IvyVersion); + + var versionOptions = versions.Select(v => v.IvyVersion).Distinct().ToArray(); + var stats = statsQuery.Value; + + var versionSelector = Layout.Horizontal().Height(Size.Fit()).Gap(2) + | Text.Block("Ivy Version:").Muted() + | selectedVersion.ToSelectInput(versionOptions); + if (stats == null) - return Layout.Center() | Text.Muted("No data yet. Run some tests first."); + return Layout.Vertical().Gap(4).Padding(4).Align(Align.TopCenter).Height(Size.Full()) + | versionSelector + | (Layout.Center() | Text.Muted("No data for this version.")); - // ── KPI cards ──────────────────────────────────────────────────────── var kpiRow = Layout.Grid().Columns(4).Gap(3).Width(Size.Fraction(ContentWidth)) | new Card( Layout.Vertical().Gap(2).Padding(3) - | Text.H3(stats.TotalRuns.ToString("N0")) - | Text.Block("questions tested across all runs").Muted() - ).Title("Total Runs").Icon(Icons.Play) + | Text.H3(stats.TotalResults.ToString("N0")) + | Text.Block("questions tested").Muted() + ).Title("Total Questions").Icon(Icons.Play) | new Card( Layout.Vertical().Gap(2).Padding(3) | Text.H3(stats.AnswerRate.ToString("F1") + "%") @@ -44,10 +70,9 @@ public class DashboardApp : ViewBase | new Card( Layout.Vertical().Gap(2).Padding(3) | Text.H3(stats.WorstWidget) - | Text.Block($"{stats.WorstWidgetRate:F1}% answer rate · most unanswered").Muted() + | Text.Block($"{stats.WorstWidgetRate:F1}% answer rate").Muted() ).Title("Weakest Widget").Icon(Icons.CircleX); - // ── Pie chart: overall distribution ────────────────────────────────── var distributionData = new[] { new { Label = "Answered", Count = stats.Answered }, @@ -59,9 +84,8 @@ public class DashboardApp : ViewBase dimension: x => x.Label, measure: x => x.Sum(f => f.Count), PieChartStyles.Dashboard, - new PieChartTotal(stats.TotalRuns.ToString("N0"), "Total")); + new PieChartTotal(stats.TotalResults.ToString("N0"), "Total")); - // ── Bar chart: answer rate per widget (worst first) ─────────────────── var answerRateData = stats.WidgetStats .OrderBy(w => w.AnswerRate) .ToList(); @@ -70,7 +94,6 @@ public class DashboardApp : ViewBase .Dimension("Widget", x => x.Widget) .Measure("Answer rate %", x => x.Sum(f => f.AnswerRate)); - // ── Bar chart: avg response time per widget (slowest first) ─────────── var responseTimeData = stats.WidgetStats .OrderByDescending(w => w.AvgMs) .ToList(); @@ -79,7 +102,6 @@ public class DashboardApp : ViewBase .Dimension("Widget", x => x.Widget) .Measure("Avg ms", x => x.Sum(f => f.AvgMs)); - // ── Bar chart: results by difficulty ───────────────────────────────── var diffData = stats.DifficultyStats; var diffChart = diffData.ToBarChart() @@ -88,26 +110,25 @@ public class DashboardApp : ViewBase .Measure("No answer", x => x.Sum(f => f.NoAnswer)) .Measure("Error", x => x.Sum(f => f.Errors)); - // ── Detail table ────────────────────────────────────────────────────── - var tableRows = stats.WidgetStats + var widgetTable = stats.WidgetStats .OrderBy(w => w.AnswerRate) .AsQueryable() .ToDataTable(r => r.Widget) .Key("dashboard-widget-table") - .Header(r => r.Widget, "Widget") - .Header(r => r.TotalRuns, "Runs") - .Header(r => r.Answered, "Answered") - .Header(r => r.NoAnswer, "No Answer") - .Header(r => r.Errors, "Errors") - .Header(r => r.AnswerRate, "Answer Rate %") - .Header(r => r.AvgMs, "Avg ms") - .Width(r => r.Widget, Size.Px(160)) - .Width(r => r.TotalRuns, Size.Px(70)) - .Width(r => r.Answered, Size.Px(90)) - .Width(r => r.NoAnswer, Size.Px(100)) - .Width(r => r.Errors, Size.Px(70)) - .Width(r => r.AnswerRate, Size.Px(120)) - .Width(r => r.AvgMs, Size.Px(80)) + .Header(r => r.Widget, "Widget") + .Header(r => r.TotalRuns, "Tested") + .Header(r => r.Answered, "Answered") + .Header(r => r.NoAnswer, "No Answer") + .Header(r => r.Errors, "Errors") + .Header(r => r.AnswerRate, "Answer Rate %") + .Header(r => r.AvgMs, "Avg ms") + .Width(r => r.Widget, Size.Px(160)) + .Width(r => r.TotalRuns, Size.Px(70)) + .Width(r => r.Answered, Size.Px(90)) + .Width(r => r.NoAnswer, Size.Px(100)) + .Width(r => r.Errors, Size.Px(70)) + .Width(r => r.AnswerRate, Size.Px(120)) + .Width(r => r.AvgMs, Size.Px(80)) .Config(c => { c.AllowSorting = true; @@ -115,7 +136,33 @@ public class DashboardApp : ViewBase c.ShowSearch = true; }); + var runHistoryTable = versions.AsQueryable() + .ToDataTable(r => r.IvyVersion) + .Key("run-history-table") + .Header(r => r.IvyVersion, "Ivy Version") + .Header(r => r.TotalQuestions, "Questions") + .Header(r => r.SuccessCount, "Answered") + .Header(r => r.NoAnswerCount, "No Answer") + .Header(r => r.ErrorCount, "Errors") + .Header(r => r.SuccessRate, "Success %") + .Header(r => r.StartedAt, "Started") + .Header(r => r.Duration, "Duration") + .Width(r => r.IvyVersion, Size.Px(120)) + .Width(r => r.TotalQuestions, Size.Px(90)) + .Width(r => r.SuccessCount, Size.Px(90)) + .Width(r => r.NoAnswerCount, Size.Px(90)) + .Width(r => r.ErrorCount, Size.Px(70)) + .Width(r => r.SuccessRate, Size.Px(100)) + .Width(r => r.StartedAt, Size.Px(170)) + .Width(r => r.Duration, Size.Px(100)) + .Config(c => + { + c.AllowSorting = true; + c.ShowIndexColumn = false; + }); + return Layout.Vertical().Gap(4).Padding(4).Align(Align.TopCenter).Height(Size.Full()) + | versionSelector | kpiRow | (Layout.Grid().Columns(2).Gap(3).Width(Size.Fraction(ContentWidth)) | new Card(pieChart).Title("Result Distribution") @@ -124,39 +171,71 @@ public class DashboardApp : ViewBase | new Card(answerRateChart).Title("Answer Rate by Widget (worst first)") | new Card(responseTimeChart).Title("Avg Response Time by Widget (slowest first)")) | (Layout.Vertical().Gap(2).Width(Size.Fraction(ContentWidth)) - | new Card(tableRows).Title("Per-Widget Breakdown")); + | new Card(widgetTable).Title("Per-Widget Breakdown")) + | (Layout.Vertical().Gap(2).Width(Size.Fraction(ContentWidth)) + | new Card(runHistoryTable).Title("Run History")); } - private static async Task LoadStatsAsync(AppDbContextFactory factory, CancellationToken ct) + private static async Task> LoadRunSummariesAsync( + AppDbContextFactory factory, CancellationToken ct) { await using var ctx = factory.CreateDbContext(); - var results = await ctx.Questions + var runs = await ctx.TestRuns .AsNoTracking() - .Where(q => q.LastRunStatus != null) + .OrderByDescending(r => r.StartedAt) + .ToListAsync(ct); + + return runs.Select(r => + { + var total = r.TotalQuestions; + var rate = total > 0 ? Math.Round(r.SuccessCount * 100.0 / total, 1) : 0; + var duration = r.CompletedAt.HasValue + ? $"{(r.CompletedAt.Value - r.StartedAt).TotalSeconds:F0}s" + : "in progress"; + var started = r.StartedAt.ToLocalTime().ToString("dd MMM yyyy, HH:mm"); + return new RunSummaryRow(r.IvyVersion, total, r.SuccessCount, r.NoAnswerCount, r.ErrorCount, rate, started, duration); + }).ToList(); + } + + private static async Task LoadStatsForVersionAsync( + AppDbContextFactory factory, string ivyVersion, CancellationToken ct) + { + await using var ctx = factory.CreateDbContext(); + + var run = await ctx.TestRuns + .AsNoTracking() + .FirstOrDefaultAsync(r => r.IvyVersion == ivyVersion, ct); + + if (run == null) return null; + + var results = await ctx.TestResults + .AsNoTracking() + .Include(r => r.Question) + .Where(r => r.TestRunId == run.Id) .ToListAsync(ct); if (results.Count == 0) return null; - var totalRuns = results.Count; - var answered = results.Count(r => r.LastRunStatus == "success"); - var noAnswer = results.Count(r => r.LastRunStatus == "no_answer"); - var errors = results.Count(r => r.LastRunStatus == "error"); - var answerRate = totalRuns > 0 ? answered * 100.0 / totalRuns : 0; - var avgMs = (int)results.Average(r => r.LastRunResponseTimeMs ?? 0); - var minMs = results.Min(r => r.LastRunResponseTimeMs ?? 0); - var maxMs = results.Max(r => r.LastRunResponseTimeMs ?? 0); + var totalResults = results.Count; + var answered = results.Count(r => r.IsSuccess); + var noAnswer = results.Count(r => !r.IsSuccess && r.HttpStatus == 404); + var errors = results.Count(r => !r.IsSuccess && r.HttpStatus != 404); + var answerRate = totalResults > 0 ? answered * 100.0 / totalResults : 0; + var avgMs = (int)results.Average(r => r.ResponseTimeMs); + var minMs = results.Min(r => r.ResponseTimeMs); + var maxMs = results.Max(r => r.ResponseTimeMs); var widgetStats = results - .GroupBy(r => r.Widget) + .GroupBy(r => r.Question.Widget) .Select(g => { var total = g.Count(); - var ans = g.Count(r => r.LastRunStatus == "success"); - var noAns = g.Count(r => r.LastRunStatus == "no_answer"); - var err = g.Count(r => r.LastRunStatus == "error"); + var ans = g.Count(r => r.IsSuccess); + var noAns = g.Count(r => !r.IsSuccess && r.HttpStatus == 404); + var err = g.Count(r => !r.IsSuccess && r.HttpStatus != 404); var rate = total > 0 ? Math.Round(ans * 100.0 / total, 1) : 0; - var avg = (int)g.Average(r => r.LastRunResponseTimeMs ?? 0); + var avg = (int)g.Average(r => r.ResponseTimeMs); return new WidgetStatRow(g.Key, total, ans, noAns, err, rate, avg); }) .OrderBy(w => w.Widget) @@ -165,19 +244,19 @@ public class DashboardApp : ViewBase var worstWidget = widgetStats.MinBy(w => w.AnswerRate); var diffStats = results - .GroupBy(r => r.Difficulty) + .GroupBy(r => r.Question.Difficulty) .Select(g => { - var ans = g.Count(r => r.LastRunStatus == "success"); - var noAns = g.Count(r => r.LastRunStatus == "no_answer"); - var err = g.Count(r => r.LastRunStatus == "error"); + var ans = g.Count(r => r.IsSuccess); + var noAns = g.Count(r => !r.IsSuccess && r.HttpStatus == 404); + var err = g.Count(r => !r.IsSuccess && r.HttpStatus != 404); return new DifficultyStatRow(g.Key, ans, noAns, err); }) .OrderBy(d => d.Difficulty == "easy" ? 0 : d.Difficulty == "medium" ? 1 : 2) .ToList(); return new DashboardStats( - TotalRuns: totalRuns, + TotalResults: totalResults, Answered: answered, NoAnswer: noAnswer, Errors: errors, @@ -192,8 +271,18 @@ public class DashboardApp : ViewBase } } +internal record RunSummaryRow( + string IvyVersion, + int TotalQuestions, + int SuccessCount, + int NoAnswerCount, + int ErrorCount, + double SuccessRate, + string StartedAt, + string Duration); + internal record DashboardStats( - int TotalRuns, + int TotalResults, int Answered, int NoAnswer, int Errors, diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs index 588c951c..fa06d797 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs @@ -11,6 +11,8 @@ private record EditRequest public string Difficulty { get; init; } = ""; public string Category { get; init; } = ""; + + public bool IsActive { get; init; } = true; } public override object? Build() @@ -36,6 +38,7 @@ private record EditRequest QuestionText = q.QuestionText ?? "", Difficulty = q.Difficulty, Category = q.Category, + IsActive = q.IsActive, }; var difficulties = new[] { "easy", "medium", "hard" }.ToOptions(); @@ -56,6 +59,7 @@ async Task OnSubmit(EditRequest? request) entity.QuestionText = request.QuestionText.Trim(); entity.Difficulty = request.Difficulty; entity.Category = request.Category.Trim(); + entity.IsActive = request.IsActive; await ctx.SaveChangesAsync(); queryService.RevalidateByTag(("widget-questions", entity.Widget)); queryService.RevalidateByTag("widget-summary"); diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index 25b1166f..e9c7f188 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -11,10 +11,13 @@ public class RunApp : ViewBase var factory = UseService(); var client = UseService(); + var ivyVersion = UseState(""); var difficultyFilter = UseState("all"); var runningIndex = UseState(-1); var completed = UseState>([]); var runQueue = UseState>([]); + var persistToDb = UseState(false); + var activeRunId = UseState(Guid.Empty); var refreshToken = UseRefreshToken(); UseEffect(() => @@ -33,21 +36,27 @@ public class RunApp : ViewBase if (idx >= questions.Count) { + if (persistToDb.Value && activeRunId.Value != Guid.Empty) + await FinalizeRunAsync(factory, activeRunId.Value, completed.Value); + runningIndex.Set(-1); runQueue.Set([]); refreshToken.Refresh(); var s = completed.Value.Count(r => r.Status == "success"); - client.Toast($"Done! {s}/{questions.Count} answered"); + var suffix = persistToDb.Value ? " (saved to DB)" : " (local only)"; + client.Toast($"Done! {s}/{questions.Count} answered{suffix}"); return; } - // Let the table paint the current row as "in progress" before awaiting the HTTP call. refreshToken.Refresh(); await Task.Yield(); var result = await IvyAskService.AskAsync(questions[idx], BaseUrl); completed.Set([.. completed.Value, result]); - _ = SaveResultAsync(factory, result); + + if (persistToDb.Value && activeRunId.Value != Guid.Empty) + _ = SaveResultAsync(factory, activeRunId.Value, result); + refreshToken.Refresh(); runningIndex.Set(idx + 1); }, [runningIndex.ToTrigger()]); @@ -97,15 +106,43 @@ public class RunApp : ViewBase return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, icon, status, time); }).ToList(); - var controls = Layout.Horizontal().Height(Size.Fit()) + var versionInput = ivyVersion.ToTextInput() + .Placeholder("e.g. v2.4.0") + .Disabled(isRunning); + + var controls = Layout.Horizontal().Height(Size.Fit()).Gap(2) + | Text.Block("Ivy Version:").Muted() + | versionInput | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(isRunning) | Text.Muted($"{questions.Count} questions") | (isRunning ? new Progress(progressPct).Goal($"{done}/{questions.Count}") : null) | new Button(isRunning ? "Running…" : "Run All", - onClick: _ => + onClick: async _ => { var snapshot = questionsQuery.Value ?? []; if (snapshot.Count == 0) return; + + var version = ivyVersion.Value.Trim(); + if (string.IsNullOrEmpty(version)) + { + client.Toast("Please enter an Ivy version before running."); + return; + } + + var shouldPersist = !await RunExistsAsync(factory, version); + persistToDb.Set(shouldPersist); + + if (shouldPersist) + { + var runId = await CreateRunAsync(factory, version, snapshot.Count); + activeRunId.Set(runId); + } + else + { + activeRunId.Set(Guid.Empty); + client.Toast($"Run for \"{version}\" already exists — results will be shown locally only."); + } + completed.Set([]); runQueue.Set(snapshot); runningIndex.Set(0); @@ -114,6 +151,15 @@ public class RunApp : ViewBase .Icon(isRunning ? Icons.Loader : Icons.Play) .Disabled(isRunning || questionsQuery.Loading || questions.Count == 0); + var statsRow = done > 0 + ? Layout.Horizontal().Height(Size.Fit()).Gap(3) + | Text.Muted($"Answered: {success}") + | Text.Muted($"No answer: {noAnswer}") + | Text.Muted($"Errors: {errors}") + | Text.Muted($"Avg: {avgMs}ms") + | (persistToDb.Value ? Text.Block("Saving to DB").Muted() : Text.Block("Local only").Muted()) + : null; + var table = rows.AsQueryable() .ToDataTable() .RefreshToken(refreshToken) @@ -142,6 +188,7 @@ public class RunApp : ViewBase return Layout.Vertical().Height(Size.Full()) | controls + | statsRow | table; } @@ -150,7 +197,7 @@ private static async Task> LoadQuestionsAsync( string difficulty) { await using var ctx = factory.CreateDbContext(); - var query = ctx.Questions.AsQueryable(); + var query = ctx.Questions.Where(q => q.IsActive); if (difficulty != "all") query = query.Where(q => q.Difficulty == difficulty); @@ -164,26 +211,68 @@ private static async Task> LoadQuestionsAsync( .ToList(); } - private static async Task SaveResultAsync(AppDbContextFactory factory, QuestionRun result) + private static async Task RunExistsAsync(AppDbContextFactory factory, string ivyVersion) + { + await using var ctx = factory.CreateDbContext(); + return await ctx.TestRuns.AnyAsync(r => r.IvyVersion == ivyVersion); + } + + private static async Task CreateRunAsync(AppDbContextFactory factory, string ivyVersion, int totalQuestions) + { + await using var ctx = factory.CreateDbContext(); + var run = new TestRunEntity + { + IvyVersion = ivyVersion, + TotalQuestions = totalQuestions, + StartedAt = DateTime.UtcNow + }; + ctx.TestRuns.Add(run); + await ctx.SaveChangesAsync(); + return run.Id; + } + + private static async Task SaveResultAsync(AppDbContextFactory factory, Guid testRunId, QuestionRun result) { try { if (!Guid.TryParse(result.Question.Id, out var questionId)) return; await using var ctx = factory.CreateDbContext(); - var entity = await ctx.Questions.FindAsync(questionId); - if (entity == null) return; + ctx.TestResults.Add(new TestResultEntity + { + TestRunId = testRunId, + QuestionId = questionId, + ResponseText = result.AnswerText, + ResponseTimeMs = result.ResponseTimeMs, + IsSuccess = result.Status == "success", + HttpStatus = result.HttpStatus, + ErrorMessage = result.Status == "error" ? result.AnswerText : null + }); + await ctx.SaveChangesAsync(); + } + catch + { + // best-effort + } + } + + private static async Task FinalizeRunAsync(AppDbContextFactory factory, Guid runId, List results) + { + try + { + await using var ctx = factory.CreateDbContext(); + var run = await ctx.TestRuns.FindAsync(runId); + if (run == null) return; - entity.LastRunStatus = result.Status; - entity.LastRunResponseTimeMs = result.ResponseTimeMs; - entity.LastRunHttpStatus = result.HttpStatus; - entity.LastRunAnswerText = result.AnswerText; - entity.LastRunAt = DateTime.UtcNow; + run.SuccessCount = results.Count(r => r.Status == "success"); + run.NoAnswerCount = results.Count(r => r.Status == "no_answer"); + run.ErrorCount = results.Count(r => r.Status == "error"); + run.CompletedAt = DateTime.UtcNow; await ctx.SaveChangesAsync(); } catch { - // silently ignore — run results are best-effort + // best-effort } } diff --git a/project-demos/ivy-ask-statistics/Connections/AppDbContext.cs b/project-demos/ivy-ask-statistics/Connections/AppDbContext.cs index dc1df377..8ddc7ecd 100644 --- a/project-demos/ivy-ask-statistics/Connections/AppDbContext.cs +++ b/project-demos/ivy-ask-statistics/Connections/AppDbContext.cs @@ -5,4 +5,35 @@ namespace IvyAskStatistics.Connections; public class AppDbContext(DbContextOptions options) : DbContext(options) { public DbSet Questions { get; set; } + public DbSet TestRuns { get; set; } + public DbSet TestResults { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasOne(r => r.TestRun) + .WithMany(tr => tr.Results) + .HasForeignKey(r => r.TestRunId) + .OnDelete(DeleteBehavior.Cascade); + + e.HasOne(r => r.Question) + .WithMany(q => q.TestResults) + .HasForeignKey(r => r.QuestionId) + .OnDelete(DeleteBehavior.Cascade); + + e.HasIndex(r => r.TestRunId); + e.HasIndex(r => r.QuestionId); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(r => r.IvyVersion); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(q => q.IsActive); + }); + } } diff --git a/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs index c753b9bd..f490ea18 100644 --- a/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs +++ b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs @@ -47,11 +47,34 @@ CREATE TABLE IF NOT EXISTS ivy_ask_questions ( "Source" VARCHAR(20) NOT NULL DEFAULT 'manual', "CreatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunStatus" VARCHAR(20); - ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunResponseTimeMs" INTEGER; - ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunHttpStatus" INTEGER; - ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunAnswerText" TEXT; - ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "LastRunAt" TIMESTAMPTZ; + ALTER TABLE ivy_ask_questions ADD COLUMN IF NOT EXISTS "IsActive" BOOLEAN NOT NULL DEFAULT TRUE; + + CREATE TABLE IF NOT EXISTS ivy_ask_test_runs ( + "Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "IvyVersion" VARCHAR(50) NOT NULL DEFAULT '', + "Environment" VARCHAR(50) NOT NULL DEFAULT 'production', + "TotalQuestions" INTEGER NOT NULL DEFAULT 0, + "SuccessCount" INTEGER NOT NULL DEFAULT 0, + "NoAnswerCount" INTEGER NOT NULL DEFAULT 0, + "ErrorCount" INTEGER NOT NULL DEFAULT 0, + "StartedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "CompletedAt" TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS ix_test_runs_ivy_version ON ivy_ask_test_runs ("IvyVersion"); + + CREATE TABLE IF NOT EXISTS ivy_ask_test_results ( + "Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "TestRunId" UUID NOT NULL REFERENCES ivy_ask_test_runs("Id") ON DELETE CASCADE, + "QuestionId" UUID NOT NULL REFERENCES ivy_ask_questions("Id") ON DELETE CASCADE, + "ResponseText" TEXT NOT NULL DEFAULT '', + "ResponseTimeMs" INTEGER NOT NULL DEFAULT 0, + "IsSuccess" BOOLEAN NOT NULL DEFAULT FALSE, + "HttpStatus" INTEGER NOT NULL DEFAULT 0, + "ErrorMessage" VARCHAR(500), + "CreatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS ix_test_results_run_id ON ivy_ask_test_results ("TestRunId"); + CREATE INDEX IF NOT EXISTS ix_test_results_question_id ON ivy_ask_test_results ("QuestionId"); """); _initialized = true; } diff --git a/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs b/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs index 1a486709..35b8e716 100644 --- a/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs +++ b/project-demos/ivy-ask-statistics/Connections/QuestionEntity.cs @@ -22,13 +22,9 @@ public class QuestionEntity [MaxLength(20)] public string Source { get; set; } = "manual"; + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - // Last run result - [MaxLength(20)] - public string? LastRunStatus { get; set; } - public int? LastRunResponseTimeMs { get; set; } - public int? LastRunHttpStatus { get; set; } - public string? LastRunAnswerText { get; set; } - public DateTime? LastRunAt { get; set; } + public List TestResults { get; set; } = []; } diff --git a/project-demos/ivy-ask-statistics/Connections/TestRunEntity.cs b/project-demos/ivy-ask-statistics/Connections/TestRunEntity.cs new file mode 100644 index 00000000..ac9ea7bd --- /dev/null +++ b/project-demos/ivy-ask-statistics/Connections/TestRunEntity.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace IvyAskStatistics.Connections; + +[Table("ivy_ask_test_runs")] +public class TestRunEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [MaxLength(50)] + public string IvyVersion { get; set; } = ""; + + [MaxLength(50)] + public string Environment { get; set; } = "production"; + + public int TotalQuestions { get; set; } + public int SuccessCount { get; set; } + public int NoAnswerCount { get; set; } + public int ErrorCount { get; set; } + + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } + + public List Results { get; set; } = []; +} From d07d56d507ce9f6463c74c27650e932e72abc0e1 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Wed, 1 Apr 2026 21:21:44 +0300 Subject: [PATCH 17/39] refactor: Simplify "Generate All Questions" functionality in QuestionsApp by removing unnecessary parameters and enhancing state management with progress tracking for better user feedback --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 172 +++++++++--------- project-demos/ivy-ask-statistics/Program.cs | 2 +- .../Services/GenerateAllBridge.cs | 12 +- 3 files changed, 96 insertions(+), 90 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 2ff8feae..1868d852 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -4,6 +4,8 @@ namespace IvyAskStatistics.Apps; internal sealed record WidgetTableData(List Rows, List Catalog, int QueryKey); +internal sealed record GenProgress(string CurrentWidget, int Done, int Total, List Failed, bool Active); + [App(icon: Icons.Database, title: "Questions")] public class QuestionsApp : ViewBase { @@ -21,8 +23,8 @@ public class QuestionsApp : ViewBase var viewDialogWidget = UseState(""); var editSheetOpen = UseState(false); var editQuestionId = UseState(Guid.Empty); - var refreshToken = UseRefreshToken(); - var generateAllPending = UseState(false); + var refreshToken = UseRefreshToken(); + var genProgress = UseState(null); var (alertView, showAlert) = UseAlert(); var tableQuery = UseQuery( @@ -45,22 +47,17 @@ public class QuestionsApp : ViewBase { await using var ctx = factory.CreateDbContext(); var list = await ctx.Questions.Where(q => q.Widget == widgetName).ToListAsync(); - if (list.Count == 0) - { - client.Toast("No questions to delete."); - return; - } + if (list.Count == 0) return; ctx.Questions.RemoveRange(list); await ctx.SaveChangesAsync(); var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); tableQuery.Mutator.Mutate(fresh, revalidate: false); refreshToken.Refresh(); - client.Toast($"Deleted {list.Count} question(s) for \"{widgetName}\"."); } - catch (Exception ex) + catch { - client.Toast($"Error: {ex.Message}"); + // best-effort } finally { @@ -68,42 +65,6 @@ public class QuestionsApp : ViewBase } }, [deleteRequest.ToTrigger()]); - UseEffect(() => - { - var action = GenerateAllBridge.Consume(); - if (action == "generate-all") - generateAllPending.Set(true); - - if (!generateAllPending.Value) return; - - var allWidgets = tableQuery.Value?.Catalog ?? []; - if (allWidgets.Count == 0) return; - - generateAllPending.Set(false); - - var allRows = tableQuery.Value?.Rows ?? []; - var notGenerated = allWidgets - .Where(w => !allRows.Any(r => r.Widget == w.Name && r.Easy + r.Medium + r.Hard > 0)) - .ToList(); - - if (notGenerated.Count == 0) - { - client.Toast("All widgets already have generated questions."); - return; - } - - showAlert( - $"Generate questions for {notGenerated.Count} widget(s) that don't have questions yet?\n\nOpenAI will be called 3 times per widget (easy / medium / hard).", - result => - { - if (!result.IsOk()) return; - MarkGenerating(notGenerated.Select(w => w.Name)); - Task.Run(() => GenerateBatchAsync(notGenerated)); - }, - "Generate All Questions", - AlertButtonSet.OkCancel); - }, EffectTrigger.OnBuild()); - async Task GenerateOneAsync(IvyWidget widget) { var apiKey = configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey secret is not set."); @@ -115,14 +76,16 @@ async Task GenerateWidgetAsync(IvyWidget widget) { try { + genProgress.Set(new GenProgress(widget.Name, 0, 1, [], true)); await GenerateOneAsync(widget); var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); tableQuery.Mutator.Mutate(fresh, revalidate: false); + genProgress.Set(new GenProgress(widget.Name, 1, 1, [], false)); } - catch (Exception ex) + catch { - client.Toast($"Error ({widget.Name}): {ex.Message}"); + genProgress.Set(new GenProgress(widget.Name, 0, 1, [widget.Name], false)); } finally { @@ -139,12 +102,14 @@ async Task GenerateBatchAsync(List widgets) foreach (var widget in widgets) { + genProgress.Set(new GenProgress(widget.Name, done, widgets.Count, failed, true)); + refreshToken.Refresh(); + var success = false; for (var attempt = 1; attempt <= maxRetries && !success; attempt++) { try { - client.Toast($"Generating {done + 1}/{widgets.Count}: {widget.Name}…"); await GenerateOneAsync(widget); success = true; } @@ -159,33 +124,66 @@ async Task GenerateBatchAsync(List widgets) done++; else failed.Add(widget.Name); + + generatingWidgets.Set(s => s.Remove(widget.Name)); + + var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); + tableQuery.Mutator.Mutate(fresh, revalidate: false); + refreshToken.Refresh(); } - var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); - tableQuery.Mutator.Mutate(fresh, revalidate: false); generatingWidgets.Set(_ => ImmutableHashSet.Empty); + genProgress.Set(new GenProgress("", done, widgets.Count, failed, false)); refreshToken.Refresh(); - - if (failed.Count == 0) - client.Toast($"Done! Generated questions for all {done} widget(s)."); - else - client.Toast($"Done: {done}/{widgets.Count} succeeded. Failed: {string.Join(", ", failed)}"); } void MarkGenerating(IEnumerable widgetNames) { var names = widgetNames.Where(n => !string.IsNullOrEmpty(n)).ToHashSet(); if (names.Count == 0) return; - generatingWidgets.Set(s => s.Union(names).ToImmutableHashSet()); refreshToken.Refresh(); } + void OnGenerateAll() + { + var allWidgets = tableQuery.Value?.Catalog ?? []; + if (allWidgets.Count == 0) return; + + var allRows = tableQuery.Value?.Rows ?? []; + var notGenerated = allWidgets + .Where(w => !allRows.Any(r => r.Widget == w.Name && r.Easy + r.Medium + r.Hard > 0)) + .ToList(); + + if (notGenerated.Count == 0) return; + + showAlert( + $"Generate questions for {notGenerated.Count} widget(s) that don't have questions yet?\n\nOpenAI will be called 3 times per widget (easy / medium / hard).", + result => + { + if (!result.IsOk()) return; + MarkGenerating(notGenerated.Select(w => w.Name)); + genProgress.Set(new GenProgress(notGenerated[0].Name, 0, notGenerated.Count, [], true)); + Task.Run(() => GenerateBatchAsync(notGenerated)); + }, + "Generate All Questions", + AlertButtonSet.OkCancel); + } + + UseEffect(() => + { + if (tableQuery.Value == null) return; + if (!GenerateAllBridge.Consume()) return; + OnGenerateAll(); + }, EffectTrigger.OnBuild()); + var generating = generatingWidgets.Value; var baseRows = tableQuery.Value?.Rows ?? []; var catalog = tableQuery.Value?.Catalog ?? []; var isDeleting = !string.IsNullOrEmpty(deleteRequest.Value); var firstLoad = tableQuery.Loading && tableQuery.Value == null; + var progress = genProgress.Value; + var isGenerating = generating.Count > 0; static string IdleStatus(WidgetRow r) { @@ -204,7 +202,34 @@ static string IdleStatus(WidgetRow r) | new Icon(Icons.Loader) | Text.Muted("Loading…"); - var isGeneratingAny = generating.Count > 0; + var notGeneratedCount = baseRows.Count(r => r.Easy + r.Medium + r.Hard == 0); + + object progressBar; + if (progress is { Active: true }) + { + var pct = progress.Total > 0 ? progress.Done * 100 / progress.Total : 0; + progressBar = new Callout( + Layout.Vertical().Gap(2) + | Text.Block($"Generating {progress.Done + 1}/{progress.Total}: {progress.CurrentWidget}…") + | new Progress(pct).Goal($"{progress.Done}/{progress.Total}"), + variant: CalloutVariant.Info); + } + else if (progress is { Active: false, Total: > 0 }) + { + var failCount = progress.Failed.Count; + progressBar = new Callout( + Layout.Horizontal().Gap(2) + | Text.Block(failCount == 0 + ? $"Done! Generated questions for {progress.Done}/{progress.Total} widget(s)." + : $"Completed: {progress.Done}/{progress.Total} succeeded. Failed: {string.Join(", ", progress.Failed)}") + | new Button("Dismiss", onClick: _ => genProgress.Set(null)).Small(), + variant: failCount == 0 ? CalloutVariant.Success : CalloutVariant.Warning); + } + else + { + progressBar = Text.Muted(""); + } + var table = rows.AsQueryable() .ToDataTable(r => r.Widget) .RefreshToken(refreshToken) @@ -245,18 +270,10 @@ static string IdleStatus(WidgetRow r) if (tag == "generate") { - if (isDeleting) - { - client.Toast("Please wait — delete in progress"); - return ValueTask.CompletedTask; - } + if (isDeleting || isGenerating) return ValueTask.CompletedTask; var genName = args.Id?.ToString() ?? ""; - if (generating.Contains(genName)) - { - client.Toast($"\"{genName}\" is already being generated"); - return ValueTask.CompletedTask; - } + if (generating.Contains(genName)) return ValueTask.CompletedTask; var widget = catalog.FirstOrDefault(w => w.Name == genName) ?? new IvyWidget(genName, rows.FirstOrDefault(r => r.Widget == genName)?.Category ?? "", ""); @@ -276,26 +293,14 @@ static string IdleStatus(WidgetRow r) if (tag == "delete") { - if (isDeleting) - { - client.Toast("Delete already in progress — please wait"); - return ValueTask.CompletedTask; - } + if (isDeleting || isGenerating) return ValueTask.CompletedTask; var delName = args.Id?.ToString() ?? ""; - if (generating.Contains(delName)) - { - client.Toast($"\"{delName}\" is currently being generated — please wait"); - return ValueTask.CompletedTask; - } + if (generating.Contains(delName)) return ValueTask.CompletedTask; var row = rows.FirstOrDefault(r => r.Widget == delName); var n = row == null ? 0 : row.Easy + row.Medium + row.Hard; - if (n == 0) - { - client.Toast("No questions to delete for this widget."); - return ValueTask.CompletedTask; - } + if (n == 0) return ValueTask.CompletedTask; showAlert( $"Delete all {n} question(s) for the \"{delName}\" widget?\n\nThis cannot be undone.", @@ -329,6 +334,7 @@ static string IdleStatus(WidgetRow r) return Layout.Vertical().Height(Size.Full()) | alertView + | progressBar | table | questionsDialog | editSheet; diff --git a/project-demos/ivy-ask-statistics/Program.cs b/project-demos/ivy-ask-statistics/Program.cs index 0500cfe8..094ad4d4 100644 --- a/project-demos/ivy-ask-statistics/Program.cs +++ b/project-demos/ivy-ask-statistics/Program.cs @@ -17,7 +17,7 @@ var list = items.ToList(); list.Add(MenuItem.Default("Generate All Questions").Icon(Icons.Sparkles).OnSelect(() => { - GenerateAllBridge.Request("generate-all"); + GenerateAllBridge.Request(); navigator.Navigate(typeof(QuestionsApp)); })); return list; diff --git a/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs b/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs index 9b9fe9a5..3fcb8da0 100644 --- a/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs +++ b/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs @@ -2,14 +2,14 @@ namespace IvyAskStatistics.Apps; public static class GenerateAllBridge { - static string? _pending; + static volatile bool _pending; - public static void Request(string action) => _pending = action; + public static void Request() => _pending = true; - public static string? Consume() + public static bool Consume() { - var val = _pending; - _pending = null; - return val; + if (!_pending) return false; + _pending = false; + return true; } } From 7383d4581209fbb23ddd3d6c177b172a0b2f7f3a Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Wed, 1 Apr 2026 21:48:55 +0300 Subject: [PATCH 18/39] refactor: Improve state management in RunApp by adding runFinished state tracking, enhancing user feedback with dynamic status updates, and optimizing KPI card display for better clarity --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 14 +-- .../ivy-ask-statistics/Apps/RunApp.cs | 107 +++++++++++++----- 2 files changed, 84 insertions(+), 37 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 1868d852..0cb40266 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -38,6 +38,13 @@ public class QuestionsApp : ViewBase options: new QueryOptions { KeepPrevious = true, RefreshInterval = TimeSpan.FromSeconds(10), RevalidateOnMount = true }, tags: ["widget-summary"]); + UseEffect(() => + { + if (tableQuery.Value == null) return; + if (!GenerateAllBridge.Consume()) return; + OnGenerateAll(); + }, EffectTrigger.OnBuild()); + UseEffect(async () => { var widgetName = deleteRequest.Value; @@ -170,13 +177,6 @@ void OnGenerateAll() AlertButtonSet.OkCancel); } - UseEffect(() => - { - if (tableQuery.Value == null) return; - if (!GenerateAllBridge.Consume()) return; - OnGenerateAll(); - }, EffectTrigger.OnBuild()); - var generating = generatingWidgets.Value; var baseRows = tableQuery.Value?.Rows ?? []; var catalog = tableQuery.Value?.Catalog ?? []; diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index e9c7f188..b717683c 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -19,12 +19,14 @@ public class RunApp : ViewBase var persistToDb = UseState(false); var activeRunId = UseState(Guid.Empty); var refreshToken = UseRefreshToken(); + var runFinished = UseState(false); UseEffect(() => { completed.Set([]); runningIndex.Set(-1); runQueue.Set([]); + runFinished.Set(false); }, [difficultyFilter.ToTrigger()]); UseEffect(async () => @@ -41,10 +43,8 @@ public class RunApp : ViewBase runningIndex.Set(-1); runQueue.Set([]); + runFinished.Set(true); refreshToken.Refresh(); - var s = completed.Value.Count(r => r.Status == "success"); - var suffix = persistToDb.Value ? " (saved to DB)" : " (local only)"; - client.Toast($"Done! {s}/{questions.Count} answered{suffix}"); return; } @@ -94,7 +94,7 @@ public class RunApp : ViewBase else if (i == runningIndex.Value) { icon = Icons.Loader; - status = "in progress"; + status = "running"; time = ""; } else @@ -106,17 +106,11 @@ public class RunApp : ViewBase return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, icon, status, time); }).ToList(); - var versionInput = ivyVersion.ToTextInput() - .Placeholder("e.g. v2.4.0") - .Disabled(isRunning); - var controls = Layout.Horizontal().Height(Size.Fit()).Gap(2) - | Text.Block("Ivy Version:").Muted() - | versionInput + | ivyVersion.ToTextInput().Placeholder("e.g. v2.4.0").Disabled(isRunning) | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(isRunning) - | Text.Muted($"{questions.Count} questions") - | (isRunning ? new Progress(progressPct).Goal($"{done}/{questions.Count}") : null) - | new Button(isRunning ? "Running…" : "Run All", + | new Badge($"{questions.Count} questions") + | new Button("Run All", onClick: async _ => { var snapshot = questionsQuery.Value ?? []; @@ -140,25 +134,78 @@ public class RunApp : ViewBase else { activeRunId.Set(Guid.Empty); - client.Toast($"Run for \"{version}\" already exists — results will be shown locally only."); } completed.Set([]); + runFinished.Set(false); runQueue.Set(snapshot); runningIndex.Set(0); }) .Primary() - .Icon(isRunning ? Icons.Loader : Icons.Play) + .Icon(Icons.Play) .Disabled(isRunning || questionsQuery.Loading || questions.Count == 0); - var statsRow = done > 0 - ? Layout.Horizontal().Height(Size.Fit()).Gap(3) - | Text.Muted($"Answered: {success}") - | Text.Muted($"No answer: {noAnswer}") - | Text.Muted($"Errors: {errors}") - | Text.Muted($"Avg: {avgMs}ms") - | (persistToDb.Value ? Text.Block("Saving to DB").Muted() : Text.Block("Local only").Muted()) - : null; + object statusBar; + if (isRunning) + { + var currentQ = runningIndex.Value < questions.Count + ? questions[runningIndex.Value] + : null; + var label = currentQ != null + ? $"Running {done + 1}/{questions.Count}: {currentQ.Widget} — {currentQ.Question}" + : $"Finishing…"; + + statusBar = new Callout( + Layout.Vertical().Gap(2) + | Text.Block(label) + | new Progress(progressPct).Goal($"{done}/{questions.Count}"), + variant: CalloutVariant.Info); + } + else if (runFinished.Value && done > 0) + { + var suffix = persistToDb.Value ? "Results saved to database." : "Local only — this version was already tested."; + var hasErrors = errors > 0; + statusBar = new Callout( + Text.Block(hasErrors + ? $"Completed: {success}/{done} answered, {noAnswer} no answer, {errors} error(s). {suffix}" + : $"Done! {success}/{done} answered, {noAnswer} no answer. {suffix}"), + variant: hasErrors ? CalloutVariant.Warning : CalloutVariant.Success); + } + else + { + statusBar = Text.Muted(""); + } + + object kpiCards; + if (done > 0) + { + var rate = done > 0 ? Math.Round(success * 100.0 / done, 1) : 0; + kpiCards = Layout.Grid().Columns(4).Gap(3).Height(Size.Fit()) + | new Card( + Layout.Vertical().Gap(2).Padding(3) + | Text.H3($"{rate}%") + | Text.Block($"{success} of {done} answered").Muted() + ).Title("Answer Rate").Icon(Icons.CircleCheck) + | new Card( + Layout.Vertical().Gap(2).Padding(3) + | Text.H3($"{noAnswer}") + | Text.Block("no answer").Muted() + ).Title("No Answer").Icon(Icons.Ban) + | new Card( + Layout.Vertical().Gap(2).Padding(3) + | Text.H3($"{errors}") + | Text.Block("failed").Muted() + ).Title("Errors").Icon(Icons.CircleX) + | new Card( + Layout.Vertical().Gap(2).Padding(3) + | Text.H3($"{avgMs} ms") + | Text.Block($"fastest {(done > 0 ? completed.Value.Min(r => r.ResponseTimeMs) : 0)} ms · slowest {(done > 0 ? completed.Value.Max(r => r.ResponseTimeMs) : 0)} ms").Muted() + ).Title("Avg Response").Icon(Icons.Timer); + } + else + { + kpiCards = Text.Muted(""); + } var table = rows.AsQueryable() .ToDataTable() @@ -169,15 +216,14 @@ public class RunApp : ViewBase .Header(r => r.Widget, "Widget") .Header(r => r.Difficulty, "Difficulty") .Header(r => r.Question, "Question") - .Header(r => r.ResultIcon, "Icon") + .Header(r => r.ResultIcon, "") .Header(r => r.Status, "Status") .Header(r => r.Time, "Time") - .Width(r => r.ResultIcon, Size.Px(50)) - .Width(r => r.Widget, Size.Px(120)) + .Width(r => r.ResultIcon, Size.Px(40)) + .Width(r => r.Widget, Size.Px(140)) .Width(r => r.Difficulty, Size.Px(80)) - .Width(r => r.Status, Size.Px(100)) + .Width(r => r.Status, Size.Px(90)) .Width(r => r.Time, Size.Px(80)) - .Width(r => r.Question, Size.Px(400)) .Config(config => { config.AllowSorting = true; @@ -186,9 +232,10 @@ public class RunApp : ViewBase config.ShowIndexColumn = true; }); - return Layout.Vertical().Height(Size.Full()) + return Layout.Vertical().Gap(3).Height(Size.Full()) | controls - | statsRow + | statusBar + | kpiCards | table; } From d0e653b5fe19d8619a045006ee351e2075c3d0ea Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Wed, 1 Apr 2026 22:17:48 +0300 Subject: [PATCH 19/39] refactor: Enhance RunApp by adding concurrency options, improving state management with Immutable collections, and optimizing question handling for better performance and user experience --- .../ivy-ask-statistics/Apps/RunApp.cs | 231 ++++++++++-------- 1 file changed, 129 insertions(+), 102 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index b717683c..aa11f7db 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -1,9 +1,13 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; + namespace IvyAskStatistics.Apps; [App(icon: Icons.ChartBar, title: "Run Tests")] public class RunApp : ViewBase { private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; + private static readonly string[] ConcurrencyOptions = ["1", "3", "5", "10", "20"]; private const string BaseUrl = "https://mcp.ivy.app"; public override object? Build() @@ -13,9 +17,11 @@ public class RunApp : ViewBase var ivyVersion = UseState(""); var difficultyFilter = UseState("all"); - var runningIndex = UseState(-1); - var completed = UseState>([]); - var runQueue = UseState>([]); + var concurrency = UseState("20"); + var isRunning = UseState(false); + var completed = UseState>(ImmutableList.Empty); + var activeIds = UseState(ImmutableHashSet.Empty); + var allQuestions = UseState>([]); var persistToDb = UseState(false); var activeRunId = UseState(Guid.Empty); var refreshToken = UseRefreshToken(); @@ -23,44 +29,12 @@ public class RunApp : ViewBase UseEffect(() => { - completed.Set([]); - runningIndex.Set(-1); - runQueue.Set([]); + completed.Set(ImmutableList.Empty); + activeIds.Set(ImmutableHashSet.Empty); + allQuestions.Set([]); runFinished.Set(false); }, [difficultyFilter.ToTrigger()]); - UseEffect(async () => - { - var idx = runningIndex.Value; - if (idx < 0) return; - - var questions = runQueue.Value; - - if (idx >= questions.Count) - { - if (persistToDb.Value && activeRunId.Value != Guid.Empty) - await FinalizeRunAsync(factory, activeRunId.Value, completed.Value); - - runningIndex.Set(-1); - runQueue.Set([]); - runFinished.Set(true); - refreshToken.Refresh(); - return; - } - - refreshToken.Refresh(); - await Task.Yield(); - - var result = await IvyAskService.AskAsync(questions[idx], BaseUrl); - completed.Set([.. completed.Value, result]); - - if (persistToDb.Value && activeRunId.Value != Guid.Empty) - _ = SaveResultAsync(factory, activeRunId.Value, result); - - refreshToken.Refresh(); - runningIndex.Set(idx + 1); - }, [runningIndex.ToTrigger()]); - var questionsQuery = UseQuery, string>( key: $"questions-{difficultyFilter.Value}", fetcher: async (_, ct) => @@ -70,94 +44,145 @@ public class RunApp : ViewBase return result; }); - var isRunning = runningIndex.Value >= 0; - var questions = isRunning && runQueue.Value.Count > 0 ? runQueue.Value : questionsQuery.Value ?? []; - - var done = completed.Value.Count; - var success = completed.Value.Count(r => r.Status == "success"); - var noAnswer = completed.Value.Count(r => r.Status == "no_answer"); - var errors = completed.Value.Count(r => r.Status == "error"); - var avgMs = done > 0 ? (int)completed.Value.Average(r => r.ResponseTimeMs) : 0; + var running = isRunning.Value; + var firstLoad = questionsQuery.Loading && questionsQuery.Value == null && !running; + var questions = running && allQuestions.Value.Count > 0 ? allQuestions.Value : questionsQuery.Value ?? []; + var completedList = completed.Value; + var active = activeIds.Value; + + var done = completedList.Count; + var success = completedList.Count(r => r.Status == "success"); + var noAnswer = completedList.Count(r => r.Status == "no_answer"); + var errors = completedList.Count(r => r.Status == "error"); + var avgMs = done > 0 ? (int)completedList.Average(r => r.ResponseTimeMs) : 0; var progressPct = questions.Count > 0 ? done * 100 / questions.Count : 0; - var rows = questions.Select((q, i) => + var completedById = completedList.ToDictionary(r => r.Question.Id); + var rows = questions.Select(q => { - var r = completed.Value.FirstOrDefault(x => x.Question.Id == q.Id); - Icons icon; - string status, time; - if (r != null) + if (completedById.TryGetValue(q.Id, out var r)) { - icon = r.Status == "success" ? Icons.CircleCheck : Icons.CircleX; - status = ToStatusLabel(r.Status); - time = $"{r.ResponseTimeMs}ms"; + var icon = r.Status == "success" ? Icons.CircleCheck : Icons.CircleX; + return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, icon, ToStatusLabel(r.Status), $"{r.ResponseTimeMs}ms"); } - else if (i == runningIndex.Value) + + if (active.Contains(q.Id)) + return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, Icons.Loader, "running", ""); + + return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, Icons.Clock, "pending", ""); + }).ToList(); + + async Task StartRunAsync() + { + var snapshot = questionsQuery.Value ?? []; + if (snapshot.Count == 0) return; + + var version = ivyVersion.Value.Trim(); + if (string.IsNullOrEmpty(version)) { - icon = Icons.Loader; - status = "running"; - time = ""; + client.Toast("Please enter an Ivy version before running."); + return; + } + + var shouldPersist = !await RunExistsAsync(factory, version); + persistToDb.Set(shouldPersist); + + Guid runId = Guid.Empty; + if (shouldPersist) + { + runId = await CreateRunAsync(factory, version, snapshot.Count); + activeRunId.Set(runId); } else { - icon = Icons.Clock; - status = "pending"; - time = ""; + activeRunId.Set(Guid.Empty); } - return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, icon, status, time); - }).ToList(); - var controls = Layout.Horizontal().Height(Size.Fit()).Gap(2) - | ivyVersion.ToTextInput().Placeholder("e.g. v2.4.0").Disabled(isRunning) - | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(isRunning) - | new Badge($"{questions.Count} questions") - | new Button("Run All", - onClick: async _ => - { - var snapshot = questionsQuery.Value ?? []; - if (snapshot.Count == 0) return; + completed.Set(ImmutableList.Empty); + activeIds.Set(ImmutableHashSet.Empty); + allQuestions.Set(snapshot); + runFinished.Set(false); + isRunning.Set(true); + refreshToken.Refresh(); + + var maxParallel = int.TryParse(concurrency.Value, out var c) ? c : 5; + + _ = Task.Run(async () => + { + var bag = new ConcurrentBag(); + var inFlight = new ConcurrentDictionary(); + using var sem = new SemaphoreSlim(maxParallel); - var version = ivyVersion.Value.Trim(); - if (string.IsNullOrEmpty(version)) + using var ticker = new PeriodicTimer(TimeSpan.FromMilliseconds(500)); + var tickerCts = new CancellationTokenSource(); + var uiTask = Task.Run(async () => + { + while (!tickerCts.IsCancellationRequested) { - client.Toast("Please enter an Ivy version before running."); - return; + try { await ticker.WaitForNextTickAsync(tickerCts.Token); } catch { break; } + completed.Set(_ => bag.ToImmutableList()); + activeIds.Set(_ => inFlight.Keys.ToImmutableHashSet()); + refreshToken.Refresh(); } + }); - var shouldPersist = !await RunExistsAsync(factory, version); - persistToDb.Set(shouldPersist); + var tasks = snapshot.Select(async q => + { + await sem.WaitAsync(); + inFlight[q.Id] = true; - if (shouldPersist) + try { - var runId = await CreateRunAsync(factory, version, snapshot.Count); - activeRunId.Set(runId); + var result = await IvyAskService.AskAsync(q, BaseUrl); + bag.Add(result); + + if (shouldPersist && runId != Guid.Empty) + _ = SaveResultAsync(factory, runId, result); } - else + finally { - activeRunId.Set(Guid.Empty); + inFlight.TryRemove(q.Id, out _); + sem.Release(); } + }); + + await Task.WhenAll(tasks); + tickerCts.Cancel(); + await uiTask; + + completed.Set(_ => bag.ToImmutableList()); + activeIds.Set(ImmutableHashSet.Empty); + + var finalResults = bag.ToList(); + if (shouldPersist && runId != Guid.Empty) + await FinalizeRunAsync(factory, runId, finalResults); + + isRunning.Set(false); + runFinished.Set(true); + refreshToken.Refresh(); + }); + } - completed.Set([]); - runFinished.Set(false); - runQueue.Set(snapshot); - runningIndex.Set(0); - }) + if (firstLoad) + return Layout.Center() + | new Icon(Icons.Loader) + | Text.Muted("Loading questions…"); + + var controls = Layout.Horizontal().Height(Size.Fit()).Gap(2) + | ivyVersion.ToTextInput().Placeholder("e.g. v2.4.0").Disabled(running) + | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(running) + | new Button("Run All", onClick: async _ => await StartRunAsync()) .Primary() .Icon(Icons.Play) - .Disabled(isRunning || questionsQuery.Loading || questions.Count == 0); + .Disabled(running || questionsQuery.Loading || questions.Count == 0); object statusBar; - if (isRunning) + if (running) { - var currentQ = runningIndex.Value < questions.Count - ? questions[runningIndex.Value] - : null; - var label = currentQ != null - ? $"Running {done + 1}/{questions.Count}: {currentQ.Widget} — {currentQ.Question}" - : $"Finishing…"; - + var inFlight = active.Count; statusBar = new Callout( Layout.Vertical().Gap(2) - | Text.Block(label) + | Text.Block($"Running {done}/{questions.Count} completed, {inFlight} in flight (x{concurrency.Value} parallel)") | new Progress(progressPct).Goal($"{done}/{questions.Count}"), variant: CalloutVariant.Info); } @@ -179,7 +204,7 @@ public class RunApp : ViewBase object kpiCards; if (done > 0) { - var rate = done > 0 ? Math.Round(success * 100.0 / done, 1) : 0; + var rate = Math.Round(success * 100.0 / done, 1); kpiCards = Layout.Grid().Columns(4).Gap(3).Height(Size.Fit()) | new Card( Layout.Vertical().Gap(2).Padding(3) @@ -199,7 +224,7 @@ public class RunApp : ViewBase | new Card( Layout.Vertical().Gap(2).Padding(3) | Text.H3($"{avgMs} ms") - | Text.Block($"fastest {(done > 0 ? completed.Value.Min(r => r.ResponseTimeMs) : 0)} ms · slowest {(done > 0 ? completed.Value.Max(r => r.ResponseTimeMs) : 0)} ms").Muted() + | Text.Block($"fastest {completedList.Min(r => r.ResponseTimeMs)} ms · slowest {completedList.Max(r => r.ResponseTimeMs)} ms").Muted() ).Title("Avg Response").Icon(Icons.Timer); } else @@ -216,11 +241,13 @@ public class RunApp : ViewBase .Header(r => r.Widget, "Widget") .Header(r => r.Difficulty, "Difficulty") .Header(r => r.Question, "Question") - .Header(r => r.ResultIcon, "") + .Header(r => r.ResultIcon, "Icon") .Header(r => r.Status, "Status") .Header(r => r.Time, "Time") - .Width(r => r.ResultIcon, Size.Px(40)) + .Width(r => r.ResultIcon, Size.Px(50)) + .Align(r => r.ResultIcon, Align.Center) .Width(r => r.Widget, Size.Px(140)) + .Width(r => r.Question, Size.Px(400)) .Width(r => r.Difficulty, Size.Px(80)) .Width(r => r.Status, Size.Px(90)) .Width(r => r.Time, Size.Px(80)) From 16403cfd3e88662ac3557d2e178f6b9e89f9c261 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Thu, 2 Apr 2026 11:04:25 +0300 Subject: [PATCH 20/39] refactor: Revamp DashboardApp and RunApp to enhance data loading, state management, and user feedback, integrating new dashboard statistics and optimizing question run persistence for improved performance --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 626 +++++++++++------- .../ivy-ask-statistics/Apps/RunApp.cs | 132 ++-- 2 files changed, 456 insertions(+), 302 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index ba463078..586c9954 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -3,309 +3,453 @@ namespace IvyAskStatistics.Apps; [App(icon: Icons.LayoutDashboard, title: "Dashboard")] public class DashboardApp : ViewBase { - private const float ContentWidth = 0.95f; - public override object? Build() { var factory = UseService(); - var selectedVersion = UseState(""); - var refreshToken = UseRefreshToken(); - - var versionsQuery = UseQuery, int>( + var dashQuery = UseQuery( key: 0, - fetcher: async (_, ct) => await LoadRunSummariesAsync(factory, ct), - options: new QueryOptions { KeepPrevious = true }); - - var statsQuery = UseQuery( - key: selectedVersion.Value, - fetcher: async (version, ct) => - { - if (string.IsNullOrEmpty(version)) return null; - return await LoadStatsForVersionAsync(factory, version, ct); - }, - options: new QueryOptions { KeepPrevious = true }); - - var versions = versionsQuery.Value ?? []; - - if (versionsQuery.Loading && versions.Count == 0) - return Layout.Center() - | new Icon(Icons.Loader) - | Text.Muted("Loading dashboard…"); - - if (versions.Count == 0) - return Layout.Center() | Text.Muted("No test runs yet. Run some tests first."); - - if (string.IsNullOrEmpty(selectedVersion.Value) && versions.Count > 0) - selectedVersion.Set(versions[0].IvyVersion); - - var versionOptions = versions.Select(v => v.IvyVersion).Distinct().ToArray(); - var stats = statsQuery.Value; - - var versionSelector = Layout.Horizontal().Height(Size.Fit()).Gap(2) - | Text.Block("Ivy Version:").Muted() - | selectedVersion.ToSelectInput(versionOptions); - - if (stats == null) - return Layout.Vertical().Gap(4).Padding(4).Align(Align.TopCenter).Height(Size.Full()) - | versionSelector - | (Layout.Center() | Text.Muted("No data for this version.")); - - var kpiRow = Layout.Grid().Columns(4).Gap(3).Width(Size.Fraction(ContentWidth)) + fetcher: async (_, ct) => await LoadDashboardPageAsync(factory, ct), + options: new QueryOptions { KeepPrevious = true, RevalidateOnMount = true }, + tags: ["dashboard-stats"]); + + if (dashQuery.Loading && dashQuery.Value == null) + return Layout.Vertical().Gap(3).Padding(4).Height(Size.Full()) + | Text.H3("Dashboard") + | Text.Muted("Loading…") + | Layout.Center().Height(Size.Units(40)) | new Icon(Icons.Loader); + + if (dashQuery.Value == null) + return Layout.Vertical().Gap(3).Padding(4).Height(Size.Full()).Align(Align.Center) + | new Icon(Icons.LayoutDashboard) + | Text.H3("No dashboard data yet") + | Text.Block( + "Run tests from the Run tab to record results. Statistics and version charts appear after the first completed run.") + .Muted() + .Width(Size.Fraction(0.5f)); + + var page = dashQuery.Value; + var data = page.Detail; + var versionTrend = page.VersionTrend; + + var header = Layout.Vertical().Gap(1) + | Text.H3("Dashboard") + | Text.Block($"Ivy {page.IvyVersion} · {page.RunStartedAt.ToLocalTime():g}").Muted(); + + // ── Level 1: Summary KPIs with deltas (vs previous Ivy version when possible) ── + var rateStr = $"{data.AnswerRate:F1}%"; + var rateDelta = data.PrevAnswerRate.HasValue + ? FormatDelta(data.AnswerRate - data.PrevAnswerRate.Value, "%", higherIsBetter: true) + : Text.Muted("first version"); + + var avgMsStr = $"{data.AvgMs} ms"; + var avgMsDelta = data.PrevAvgMs.HasValue + ? FormatDelta(data.AvgMs - data.PrevAvgMs.Value, "ms", higherIsBetter: false) + : Text.Muted("first version"); + + var failedCount = data.NoAnswer + data.Errors; + + var kpiRow = Layout.Grid().Columns(4).Gap(3).Height(Size.Fit()) | new Card( Layout.Vertical().Gap(2).Padding(3) - | Text.H3(stats.TotalResults.ToString("N0")) - | Text.Block("questions tested").Muted() - ).Title("Total Questions").Icon(Icons.Play) + | Text.H3(rateStr) + | rateDelta + ).Title("Success Rate").Icon(Icons.CircleCheck) | new Card( Layout.Vertical().Gap(2).Padding(3) - | Text.H3(stats.AnswerRate.ToString("F1") + "%") - | Text.Block($"{stats.Answered} answered / {stats.NoAnswer} no answer / {stats.Errors} errors").Muted() - ).Title("Answer Rate").Icon(Icons.CircleCheck) + | Text.H3(avgMsStr) + | avgMsDelta + ).Title("Avg Response").Icon(Icons.Timer) | new Card( Layout.Vertical().Gap(2).Padding(3) - | Text.H3(stats.AvgResponseMs + " ms") - | Text.Block($"fastest {stats.MinResponseMs} ms · slowest {stats.MaxResponseMs} ms").Muted() - ).Title("Avg Response Time").Icon(Icons.Timer) + | Text.H3(failedCount.ToString()) + | Text.Block($"{data.NoAnswer} no answer · {data.Errors} errors").Muted() + ).Title("Failed Questions").Icon(Icons.CircleX) | new Card( Layout.Vertical().Gap(2).Padding(3) - | Text.H3(stats.WorstWidget) - | Text.Block($"{stats.WorstWidgetRate:F1}% answer rate").Muted() - ).Title("Weakest Widget").Icon(Icons.CircleX); + | Text.H3(data.WorstWidgets.Count > 0 ? data.WorstWidgets[0].Widget : "—") + | Text.Block(data.WorstWidgets.Count > 0 ? $"{data.WorstWidgets[0].AnswerRate:F1}% answer rate" : "").Muted() + ).Title("Weakest Widget").Icon(Icons.Ban); - var distributionData = new[] + // ── Version history (one point = latest completed run per Ivy version) ── + object versionChartsRow; + if (versionTrend.Count >= 1) { - new { Label = "Answered", Count = stats.Answered }, - new { Label = "No answer", Count = stats.NoAnswer }, - new { Label = "Error", Count = stats.Errors } - }.Where(x => x.Count > 0).ToList(); - - var pieChart = distributionData.ToPieChart( - dimension: x => x.Label, - measure: x => x.Sum(f => f.Count), - PieChartStyles.Dashboard, - new PieChartTotal(stats.TotalResults.ToString("N0"), "Total")); - - var answerRateData = stats.WidgetStats - .OrderBy(w => w.AnswerRate) - .ToList(); + var rateByVersion = versionTrend.ToBarChart() + .Dimension("Version", x => x.Version) + .Measure("Success %", x => x.Sum(f => f.AnswerRate)); + + var latencyByVersion = versionTrend.ToBarChart() + .Dimension("Version", x => x.Version) + .Measure("Avg ms", x => x.Sum(f => f.AvgMs)); + + var outcomesByVersion = versionTrend.ToBarChart() + .Dimension("Version", x => x.Version) + .Measure("Answered", x => x.Sum(f => f.Answered)) + .Measure("No answer", x => x.Sum(f => f.NoAnswer)) + .Measure("Error", x => x.Sum(f => f.Errors)); + + versionChartsRow = Layout.Grid().Columns(3).Gap(3).Height(Size.Fit()) + | new Card(rateByVersion).Title("Success rate by Ivy version").Height(Size.Units(70)) + | new Card(latencyByVersion).Title("Avg response by Ivy version").Height(Size.Units(70)) + | new Card(outcomesByVersion).Title("Outcomes by Ivy version").Height(Size.Units(70)); + } + else + { + versionChartsRow = Layout.Vertical(); + } - var answerRateChart = answerRateData.ToBarChart() + // ── Level 2: Problem tables ── + var worstTable = data.WorstWidgets.AsQueryable() + .ToDataTable(r => r.Widget) + .Key("worst-widgets") + .Header(r => r.Widget, "Widget") + .Header(r => r.AnswerRate, "Rate %") + .Header(r => r.Failed, "Failed") + .Header(r => r.Tested, "Tested") + .Width(r => r.Widget, Size.Px(140)) + .Width(r => r.AnswerRate, Size.Px(70)) + .Width(r => r.Failed, Size.Px(70)) + .Width(r => r.Tested, Size.Px(70)) + .Config(c => { c.ShowIndexColumn = false; c.AllowSorting = true; }); + + var slowestTable = data.SlowestWidgets.AsQueryable() + .ToDataTable(r => r.Widget) + .Key("slowest-widgets") + .Header(r => r.Widget, "Widget") + .Header(r => r.AvgMs, "Avg ms") + .Header(r => r.MaxMs, "Max ms") + .Width(r => r.Widget, Size.Px(140)) + .Width(r => r.AvgMs, Size.Px(80)) + .Width(r => r.MaxMs, Size.Px(80)) + .Config(c => { c.ShowIndexColumn = false; c.AllowSorting = true; }); + + var worstChart = data.WorstWidgets.ToBarChart() .Dimension("Widget", x => x.Widget) .Measure("Answer rate %", x => x.Sum(f => f.AnswerRate)); - var responseTimeData = stats.WidgetStats - .OrderByDescending(w => w.AvgMs) - .ToList(); - - var responseTimeChart = responseTimeData.ToBarChart() - .Dimension("Widget", x => x.Widget) - .Measure("Avg ms", x => x.Sum(f => f.AvgMs)); + var resultDistribution = new[] + { + new { Label = "Answered", Count = data.Answered }, + new { Label = "No answer", Count = data.NoAnswer }, + new { Label = "Error", Count = data.Errors } + }.Where(x => x.Count > 0).ToList(); - var diffData = stats.DifficultyStats; + var pieChart = resultDistribution.ToPieChart( + dimension: x => x.Label, + measure: x => x.Sum(f => f.Count), + PieChartStyles.Dashboard, + new PieChartTotal(data.Total.ToString("N0"), "Total")); - var diffChart = diffData.ToBarChart() + var difficultyChart = data.DifficultyBreakdown.ToBarChart() .Dimension("Difficulty", x => x.Difficulty) - .Measure("Answered", x => x.Sum(f => f.Answered)) + .Measure("Answered", x => x.Sum(f => f.Answered)) .Measure("No answer", x => x.Sum(f => f.NoAnswer)) - .Measure("Error", x => x.Sum(f => f.Errors)); - - var widgetTable = stats.WidgetStats - .OrderBy(w => w.AnswerRate) - .AsQueryable() - .ToDataTable(r => r.Widget) - .Key("dashboard-widget-table") - .Header(r => r.Widget, "Widget") - .Header(r => r.TotalRuns, "Tested") - .Header(r => r.Answered, "Answered") - .Header(r => r.NoAnswer, "No Answer") - .Header(r => r.Errors, "Errors") - .Header(r => r.AnswerRate, "Answer Rate %") - .Header(r => r.AvgMs, "Avg ms") - .Width(r => r.Widget, Size.Px(160)) - .Width(r => r.TotalRuns, Size.Px(70)) - .Width(r => r.Answered, Size.Px(90)) - .Width(r => r.NoAnswer, Size.Px(100)) - .Width(r => r.Errors, Size.Px(70)) - .Width(r => r.AnswerRate, Size.Px(120)) - .Width(r => r.AvgMs, Size.Px(80)) + .Measure("Error", x => x.Sum(f => f.Errors)); + + var chartsRow = Layout.Grid().Columns(3).Gap(3).Height(Size.Fit()) + | new Card(worstChart).Title("Worst Widgets").Height(Size.Units(70)) + | new Card(difficultyChart).Title("Results by Difficulty").Height(Size.Units(70)) + | new Card(pieChart).Title("Result Distribution").Height(Size.Units(70)); + + var problemRow = Layout.Grid().Columns(2).Gap(3).Height(Size.Fit()) + | new Card(worstTable).Title("Worst Widgets (top 10)") + | new Card(slowestTable).Title("Slowest Widgets (top 10)"); + + // ── Level 3: Failed questions debug table ── + var failedTable = data.FailedQuestions.AsQueryable() + .ToDataTable() + .Key("failed-questions") + .Height(Size.Full()) + .Header(r => r.Widget, "Widget") + .Header(r => r.Difficulty, "Difficulty") + .Header(r => r.Question, "Question") + .Header(r => r.Status, "Status") + .Header(r => r.ResponseTimeMs, "Time (ms)") + .Width(r => r.Widget, Size.Px(140)) + .Width(r => r.Difficulty, Size.Px(90)) + .Width(r => r.Status, Size.Px(90)) + .Width(r => r.ResponseTimeMs, Size.Px(90)) .Config(c => { - c.AllowSorting = true; + c.AllowSorting = true; c.AllowFiltering = true; - c.ShowSearch = true; - }); - - var runHistoryTable = versions.AsQueryable() - .ToDataTable(r => r.IvyVersion) - .Key("run-history-table") - .Header(r => r.IvyVersion, "Ivy Version") - .Header(r => r.TotalQuestions, "Questions") - .Header(r => r.SuccessCount, "Answered") - .Header(r => r.NoAnswerCount, "No Answer") - .Header(r => r.ErrorCount, "Errors") - .Header(r => r.SuccessRate, "Success %") - .Header(r => r.StartedAt, "Started") - .Header(r => r.Duration, "Duration") - .Width(r => r.IvyVersion, Size.Px(120)) - .Width(r => r.TotalQuestions, Size.Px(90)) - .Width(r => r.SuccessCount, Size.Px(90)) - .Width(r => r.NoAnswerCount, Size.Px(90)) - .Width(r => r.ErrorCount, Size.Px(70)) - .Width(r => r.SuccessRate, Size.Px(100)) - .Width(r => r.StartedAt, Size.Px(170)) - .Width(r => r.Duration, Size.Px(100)) - .Config(c => - { - c.AllowSorting = true; - c.ShowIndexColumn = false; + c.ShowSearch = true; + c.ShowIndexColumn = true; }); - return Layout.Vertical().Gap(4).Padding(4).Align(Align.TopCenter).Height(Size.Full()) - | versionSelector + return Layout.Vertical().Gap(3).Padding(4).Height(Size.Full()) + | header | kpiRow - | (Layout.Grid().Columns(2).Gap(3).Width(Size.Fraction(ContentWidth)) - | new Card(pieChart).Title("Result Distribution") - | new Card(diffChart).Title("Results by Difficulty")) - | (Layout.Grid().Columns(2).Gap(3).Width(Size.Fraction(ContentWidth)) - | new Card(answerRateChart).Title("Answer Rate by Widget (worst first)") - | new Card(responseTimeChart).Title("Avg Response Time by Widget (slowest first)")) - | (Layout.Vertical().Gap(2).Width(Size.Fraction(ContentWidth)) - | new Card(widgetTable).Title("Per-Widget Breakdown")) - | (Layout.Vertical().Gap(2).Width(Size.Fraction(ContentWidth)) - | new Card(runHistoryTable).Title("Run History")); + | versionChartsRow + | chartsRow + | problemRow + | new Card(failedTable).Title($"Failed Questions ({failedCount})"); } - private static async Task> LoadRunSummariesAsync( + private static object FormatDelta(double delta, string unit, bool higherIsBetter) + { + if (Math.Abs(delta) < 0.05) return Text.Muted("no change"); + var sign = delta > 0 ? "+" : ""; + var label = unit == "%" ? $"{sign}{delta:F1}{unit}" : $"{sign}{(int)delta} {unit}"; + var isGood = higherIsBetter ? delta > 0 : delta < 0; + return isGood ? Text.Block(label).Color(Colors.Emerald) : Text.Block(label).Color(Colors.Red); + } + + private static async Task LoadDashboardPageAsync( AppDbContextFactory factory, CancellationToken ct) { await using var ctx = factory.CreateDbContext(); - var runs = await ctx.TestRuns - .AsNoTracking() - .OrderByDescending(r => r.StartedAt) + var runIdsWithData = await ctx.TestResults.AsNoTracking() + .Select(r => r.TestRunId) + .Distinct() .ToListAsync(ct); - return runs.Select(r => - { - var total = r.TotalQuestions; - var rate = total > 0 ? Math.Round(r.SuccessCount * 100.0 / total, 1) : 0; - var duration = r.CompletedAt.HasValue - ? $"{(r.CompletedAt.Value - r.StartedAt).TotalSeconds:F0}s" - : "in progress"; - var started = r.StartedAt.ToLocalTime().ToString("dd MMM yyyy, HH:mm"); - return new RunSummaryRow(r.IvyVersion, total, r.SuccessCount, r.NoAnswerCount, r.ErrorCount, rate, started, duration); - }).ToList(); - } + if (runIdsWithData.Count == 0) return null; - private static async Task LoadStatsForVersionAsync( - AppDbContextFactory factory, string ivyVersion, CancellationToken ct) - { - await using var ctx = factory.CreateDbContext(); + var runs = await ctx.TestRuns.AsNoTracking() + .Where(r => r.CompletedAt != null && runIdsWithData.Contains(r.Id)) + .OrderByDescending(r => r.StartedAt) + .ToListAsync(ct); - var run = await ctx.TestRuns - .AsNoTracking() - .FirstOrDefaultAsync(r => r.IvyVersion == ivyVersion, ct); + if (runs.Count == 0) return null; - if (run == null) return null; + var latestRun = runs[0]; - var results = await ctx.TestResults - .AsNoTracking() + var seenVersions = new HashSet(StringComparer.OrdinalIgnoreCase); + var latestRunPerVersion = new List(); + foreach (var r in runs) + { + var v = (r.IvyVersion ?? "").Trim(); + if (string.IsNullOrEmpty(v)) continue; + if (!seenVersions.Add(v)) continue; + latestRunPerVersion.Add(r); + } + + var trendRunIds = latestRunPerVersion.Select(r => r.Id).ToList(); + var trendResults = await ctx.TestResults.AsNoTracking() .Include(r => r.Question) - .Where(r => r.TestRunId == run.Id) + .Where(r => trendRunIds.Contains(r.TestRunId)) .ToListAsync(ct); - if (results.Count == 0) return null; + // Use TestRun row counters for outcomes/rate so charts match ivy_ask_test_runs (FinalizeRun totals). + // Row-level ivy_ask_test_results can be short if historical runs failed mid-persist; averages still use rows when present. + var versionTrend = new List(); + foreach (var r in latestRunPerVersion) + { + var res = trendResults.Where(x => x.TestRunId == r.Id).ToList(); + var answered = r.SuccessCount; + var noAnswer = r.NoAnswerCount; + var errors = r.ErrorCount; + var t = r.TotalQuestions; + var rate = t > 0 ? Math.Round(answered * 100.0 / t, 1) : 0; + var avgMs = res.Count > 0 ? Math.Round(res.Average(x => (double)x.ResponseTimeMs), 0) : 0; + versionTrend.Add(new VersionTrendRow( + (r.IvyVersion ?? "").Trim(), + rate, + avgMs, + answered, + noAnswer, + errors, + t, + r.StartedAt)); + } + + versionTrend.Sort((a, b) => CompareVersionStrings(a.Version, b.Version)); + + var latestResults = await ctx.TestResults.AsNoTracking() + .Include(r => r.Question) + .Where(r => r.TestRunId == latestRun.Id) + .ToListAsync(ct); + + if (latestResults.Count == 0 && (latestRun.CompletedAt == null || latestRun.TotalQuestions == 0)) + return null; + + double? prevAnswerRate = null; + int? prevAvgMs = null; + var currentV = (latestRun.IvyVersion ?? "").Trim(); + var idx = versionTrend.FindIndex(r => string.Equals(r.Version, currentV, StringComparison.OrdinalIgnoreCase)); + if (idx > 0) + { + var prev = versionTrend[idx - 1]; + prevAnswerRate = prev.AnswerRate; + prevAvgMs = (int)prev.AvgMs; + } + else + { + var prevRun = await ctx.TestRuns.AsNoTracking() + .Where(r => r.StartedAt < latestRun.StartedAt && r.CompletedAt != null && runIdsWithData.Contains(r.Id)) + .OrderByDescending(r => r.StartedAt) + .FirstOrDefaultAsync(ct); + if (prevRun != null) + { + var prevResults = await ctx.TestResults.AsNoTracking() + .Where(r => r.TestRunId == prevRun.Id) + .ToListAsync(ct); + if (prevRun.TotalQuestions > 0 && prevResults.Count == prevRun.TotalQuestions) + { + var prevAns = prevResults.Count(r => r.IsSuccess); + prevAnswerRate = Math.Round(prevAns * 100.0 / prevResults.Count, 1); + prevAvgMs = (int)prevResults.Average(r => r.ResponseTimeMs); + } + else if (prevRun.TotalQuestions > 0) + { + prevAnswerRate = Math.Round(prevRun.SuccessCount * 100.0 / prevRun.TotalQuestions, 1); + prevAvgMs = prevResults.Count > 0 ? (int)prevResults.Average(r => r.ResponseTimeMs) : 0; + } + } + } + + var detail = BuildDashboardData(latestResults, prevAnswerRate, prevAvgMs, latestRun); + + return new DashboardPageModel( + currentV, + latestRun.StartedAt, + detail, + versionTrend); + } - var totalResults = results.Count; - var answered = results.Count(r => r.IsSuccess); - var noAnswer = results.Count(r => !r.IsSuccess && r.HttpStatus == 404); - var errors = results.Count(r => !r.IsSuccess && r.HttpStatus != 404); - var answerRate = totalResults > 0 ? answered * 100.0 / totalResults : 0; - var avgMs = (int)results.Average(r => r.ResponseTimeMs); - var minMs = results.Min(r => r.ResponseTimeMs); - var maxMs = results.Max(r => r.ResponseTimeMs); + private static DashboardData BuildDashboardData( + List results, + double? prevAnswerRate, + int? prevAvgMs, + TestRunEntity? run = null) + { + var useRunSummary = run != null && run.TotalQuestions > 0 + && (results.Count == 0 || results.Count != run.TotalQuestions); - var widgetStats = results + int total, answered, noAnswer, errors; + double answerRate; + int avgMs; + if (useRunSummary) + { + total = run!.TotalQuestions; + answered = run.SuccessCount; + noAnswer = run.NoAnswerCount; + errors = run.ErrorCount; + answerRate = total > 0 ? Math.Round(answered * 100.0 / total, 1) : 0; + avgMs = results.Count > 0 ? (int)results.Average(r => r.ResponseTimeMs) : 0; + } + else if (results.Count == 0) + { + return new DashboardData( + 0, 0, 0, 0, 0, 0, prevAnswerRate, prevAvgMs, + [], [], [], []); + } + else + { + total = results.Count; + answered = results.Count(r => r.IsSuccess); + noAnswer = results.Count(r => !r.IsSuccess && r.HttpStatus == 404); + errors = results.Count(r => !r.IsSuccess && r.HttpStatus != 404); + answerRate = total > 0 ? Math.Round(answered * 100.0 / total, 1) : 0; + avgMs = (int)results.Average(r => r.ResponseTimeMs); + } + + var widgetGroups = results .GroupBy(r => r.Question.Widget) .Select(g => { - var total = g.Count(); - var ans = g.Count(r => r.IsSuccess); - var noAns = g.Count(r => !r.IsSuccess && r.HttpStatus == 404); - var err = g.Count(r => !r.IsSuccess && r.HttpStatus != 404); - var rate = total > 0 ? Math.Round(ans * 100.0 / total, 1) : 0; - var avg = (int)g.Average(r => r.ResponseTimeMs); - return new WidgetStatRow(g.Key, total, ans, noAns, err, rate, avg); + var t = g.Count(); + var a = g.Count(r => r.IsSuccess); + var rate = t > 0 ? Math.Round(a * 100.0 / t, 1) : 0; + var avg = (int)g.Average(r => r.ResponseTimeMs); + var max = g.Max(r => r.ResponseTimeMs); + return new WidgetProblem(g.Key, rate, t - a, t, avg, max); }) - .OrderBy(w => w.Widget) .ToList(); - var worstWidget = widgetStats.MinBy(w => w.AnswerRate); + var worstWidgets = widgetGroups.OrderBy(w => w.AnswerRate).Take(10).ToList(); + var slowestWidgets = widgetGroups.OrderByDescending(w => w.AvgMs).Take(10).ToList(); - var diffStats = results + var diffBreakdown = results .GroupBy(r => r.Question.Difficulty) .Select(g => { - var ans = g.Count(r => r.IsSuccess); - var noAns = g.Count(r => !r.IsSuccess && r.HttpStatus == 404); - var err = g.Count(r => !r.IsSuccess && r.HttpStatus != 404); - return new DifficultyStatRow(g.Key, ans, noAns, err); + var t = g.Count(); + var a = g.Count(r => r.IsSuccess); + var na = g.Count(r => !r.IsSuccess && r.HttpStatus == 404); + var err = g.Count(r => !r.IsSuccess && r.HttpStatus != 404); + var rate = t > 0 ? Math.Round(a * 100.0 / t, 1) : 0; + return new DifficultyRow(g.Key, rate, a, na, err, t); }) .OrderBy(d => d.Difficulty == "easy" ? 0 : d.Difficulty == "medium" ? 1 : 2) .ToList(); - return new DashboardStats( - TotalResults: totalResults, - Answered: answered, - NoAnswer: noAnswer, - Errors: errors, - AnswerRate: Math.Round(answerRate, 1), - AvgResponseMs: avgMs, - MinResponseMs: minMs, - MaxResponseMs: maxMs, - WorstWidget: worstWidget?.Widget ?? "—", - WorstWidgetRate: worstWidget?.AnswerRate ?? 0, - WidgetStats: widgetStats, - DifficultyStats: diffStats); + var failedQuestions = results + .Where(r => !r.IsSuccess) + .OrderBy(r => r.Question.Widget) + .ThenBy(r => r.Question.Difficulty) + .Select(r => new FailedQuestion( + r.Question.Widget, + r.Question.Difficulty, + r.Question.QuestionText, + r.HttpStatus == 404 ? "no answer" : "error", + r.ResponseTimeMs)) + .ToList(); + + return new DashboardData( + total, answered, noAnswer, errors, answerRate, avgMs, + prevAnswerRate, prevAvgMs, + worstWidgets, slowestWidgets, diffBreakdown, failedQuestions); + } + + /// Semantic-ish ordering so 1.2.26 < 1.2.27 < 1.10.0. + private static int CompareVersionStrings(string? a, string? b) + { + if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)) return 0; + if (string.IsNullOrEmpty(a)) return -1; + if (string.IsNullOrEmpty(b)) return 1; + + var pa = a.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var pb = b.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var n = Math.Max(pa.Length, pb.Length); + for (var i = 0; i < n; i++) + { + var sa = i < pa.Length ? pa[i] : ""; + var sb = i < pb.Length ? pb[i] : ""; + var na = int.TryParse(sa, out var ia) ? ia : int.MinValue; + var nb = int.TryParse(sb, out var ib) ? ib : int.MinValue; + if (na != int.MinValue && nb != int.MinValue) + { + if (na != nb) return na.CompareTo(nb); + continue; + } + + var cmp = string.Compare(sa, sb, StringComparison.OrdinalIgnoreCase); + if (cmp != 0) return cmp; + } + + return string.Compare(a, b, StringComparison.OrdinalIgnoreCase); } } -internal record RunSummaryRow( +internal record DashboardPageModel( string IvyVersion, - int TotalQuestions, - int SuccessCount, - int NoAnswerCount, - int ErrorCount, - double SuccessRate, - string StartedAt, - string Duration); - -internal record DashboardStats( - int TotalResults, - int Answered, - int NoAnswer, - int Errors, - double AnswerRate, - int AvgResponseMs, - int MinResponseMs, - int MaxResponseMs, - string WorstWidget, - double WorstWidgetRate, - List WidgetStats, - List DifficultyStats); - -internal record WidgetStatRow( - string Widget, - int TotalRuns, - int Answered, - int NoAnswer, - int Errors, - double AnswerRate, - int AvgMs); + DateTime RunStartedAt, + DashboardData Detail, + List VersionTrend); -internal record DifficultyStatRow( - string Difficulty, - int Answered, - int NoAnswer, - int Errors); +internal record VersionTrendRow( + string Version, + double AnswerRate, + double AvgMs, + int Answered, + int NoAnswer, + int Errors, + int Total, + DateTime RunAt); + +internal record DashboardData( + int Total, int Answered, int NoAnswer, int Errors, + double AnswerRate, int AvgMs, + double? PrevAnswerRate, int? PrevAvgMs, + List WorstWidgets, + List SlowestWidgets, + List DifficultyBreakdown, + List FailedQuestions); + +internal record WidgetProblem(string Widget, double AnswerRate, int Failed, int Tested, int AvgMs, int MaxMs); +internal record DifficultyRow(string Difficulty, double Rate, int Answered, int NoAnswer, int Errors, int Total); +internal record FailedQuestion(string Widget, string Difficulty, string Question, string Status, int ResponseTimeMs); diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index aa11f7db..e26e34c0 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -14,6 +14,7 @@ public class RunApp : ViewBase { var factory = UseService(); var client = UseService(); + var queryService = UseService(); var ivyVersion = UseState(""); var difficultyFilter = UseState("all"); @@ -23,7 +24,6 @@ public class RunApp : ViewBase var activeIds = UseState(ImmutableHashSet.Empty); var allQuestions = UseState>([]); var persistToDb = UseState(false); - var activeRunId = UseState(Guid.Empty); var refreshToken = UseRefreshToken(); var runFinished = UseState(false); @@ -87,17 +87,6 @@ async Task StartRunAsync() var shouldPersist = !await RunExistsAsync(factory, version); persistToDb.Set(shouldPersist); - Guid runId = Guid.Empty; - if (shouldPersist) - { - runId = await CreateRunAsync(factory, version, snapshot.Count); - activeRunId.Set(runId); - } - else - { - activeRunId.Set(Guid.Empty); - } - completed.Set(ImmutableList.Empty); activeIds.Set(ImmutableHashSet.Empty); allQuestions.Set(snapshot); @@ -109,6 +98,7 @@ async Task StartRunAsync() _ = Task.Run(async () => { + var runStartedUtc = DateTime.UtcNow; var bag = new ConcurrentBag(); var inFlight = new ConcurrentDictionary(); using var sem = new SemaphoreSlim(maxParallel); @@ -135,9 +125,6 @@ async Task StartRunAsync() { var result = await IvyAskService.AskAsync(q, BaseUrl); bag.Add(result); - - if (shouldPersist && runId != Guid.Empty) - _ = SaveResultAsync(factory, runId, result); } finally { @@ -153,9 +140,15 @@ async Task StartRunAsync() completed.Set(_ => bag.ToImmutableList()); activeIds.Set(ImmutableHashSet.Empty); - var finalResults = bag.ToList(); - if (shouldPersist && runId != Guid.Empty) - await FinalizeRunAsync(factory, runId, finalResults); + var finalResults = OrderResultsLikeSnapshot(snapshot, bag.ToList()); + if (shouldPersist) + { + var saved = await PersistNewRunAsync(factory, version, snapshot, finalResults, runStartedUtc); + if (saved) + queryService.RevalidateByTag("dashboard-stats"); + else + client.Toast("Could not save results to the database."); + } isRunning.Set(false); runFinished.Set(true); @@ -291,62 +284,79 @@ private static async Task RunExistsAsync(AppDbContextFactory factory, stri return await ctx.TestRuns.AnyAsync(r => r.IvyVersion == ivyVersion); } - private static async Task CreateRunAsync(AppDbContextFactory factory, string ivyVersion, int totalQuestions) + /// + /// One row per question in order (fills gaps if the bag is short). + /// + private static List OrderResultsLikeSnapshot( + IReadOnlyList snapshot, + List bag) { - await using var ctx = factory.CreateDbContext(); - var run = new TestRunEntity - { - IvyVersion = ivyVersion, - TotalQuestions = totalQuestions, - StartedAt = DateTime.UtcNow - }; - ctx.TestRuns.Add(run); - await ctx.SaveChangesAsync(); - return run.Id; + var byId = bag + .GroupBy(r => r.Question.Id) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + return snapshot + .Select(q => byId.TryGetValue(q.Id, out var r) + ? r + : new QuestionRun(q, "error", 0, 0, "")) + .ToList(); } - private static async Task SaveResultAsync(AppDbContextFactory factory, Guid testRunId, QuestionRun result) + /// + /// Single transaction: create run + insert every test result, or roll back (no orphan run / partial rows). + /// + private static async Task PersistNewRunAsync( + AppDbContextFactory factory, + string ivyVersion, + IReadOnlyList snapshot, + List ordered, + DateTime startedAtUtc) { - try - { - if (!Guid.TryParse(result.Question.Id, out var questionId)) return; - await using var ctx = factory.CreateDbContext(); - ctx.TestResults.Add(new TestResultEntity - { - TestRunId = testRunId, - QuestionId = questionId, - ResponseText = result.AnswerText, - ResponseTimeMs = result.ResponseTimeMs, - IsSuccess = result.Status == "success", - HttpStatus = result.HttpStatus, - ErrorMessage = result.Status == "error" ? result.AnswerText : null - }); - await ctx.SaveChangesAsync(); - } - catch - { - // best-effort - } - } + if (ordered.Count != snapshot.Count) + return false; - private static async Task FinalizeRunAsync(AppDbContextFactory factory, Guid runId, List results) - { + await using var ctx = factory.CreateDbContext(); + await using var tx = await ctx.Database.BeginTransactionAsync(); try { - await using var ctx = factory.CreateDbContext(); - var run = await ctx.TestRuns.FindAsync(runId); - if (run == null) return; + var run = new TestRunEntity + { + IvyVersion = ivyVersion, + TotalQuestions = snapshot.Count, + StartedAt = startedAtUtc, + SuccessCount = ordered.Count(r => r.Status == "success"), + NoAnswerCount = ordered.Count(r => r.Status == "no_answer"), + ErrorCount = ordered.Count(r => r.Status == "error"), + CompletedAt = DateTime.UtcNow + }; + ctx.TestRuns.Add(run); + + var rows = new List(ordered.Count); + foreach (var result in ordered) + { + if (!Guid.TryParse(result.Question.Id, out var questionId)) + throw new InvalidOperationException($"Invalid question id: {result.Question.Id}"); - run.SuccessCount = results.Count(r => r.Status == "success"); - run.NoAnswerCount = results.Count(r => r.Status == "no_answer"); - run.ErrorCount = results.Count(r => r.Status == "error"); - run.CompletedAt = DateTime.UtcNow; + rows.Add(new TestResultEntity + { + TestRunId = run.Id, + QuestionId = questionId, + ResponseText = result.AnswerText ?? "", + ResponseTimeMs = result.ResponseTimeMs, + IsSuccess = result.Status == "success", + HttpStatus = result.HttpStatus, + ErrorMessage = result.Status == "error" ? result.AnswerText : null + }); + } + ctx.TestResults.AddRange(rows); await ctx.SaveChangesAsync(); + await tx.CommitAsync(); + return true; } catch { - // best-effort + await tx.RollbackAsync(); + return false; } } From 281db29936c4a072cb7eecfb8272d0617ba36cb8 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Thu, 2 Apr 2026 11:44:24 +0300 Subject: [PATCH 21/39] refactor: Introduce TabLoadingSkeletons for improved loading indicators across DashboardApp, RunApp, and QuestionsApp, enhancing user experience with consistent placeholders during data fetching --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 95 +++++++++++-------- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 8 +- .../ivy-ask-statistics/Apps/RunApp.cs | 20 ++-- .../Apps/TabLoadingSkeletons.cs | 63 ++++++++++++ .../Apps/WidgetQuestionsDialog.cs | 6 +- 5 files changed, 134 insertions(+), 58 deletions(-) create mode 100644 project-demos/ivy-ask-statistics/Apps/TabLoadingSkeletons.cs diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index 586c9954..d122b109 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -6,70 +6,90 @@ public class DashboardApp : ViewBase public override object? Build() { var factory = UseService(); + var client = UseService(); + var queryService = UseService(); + var navigation = Context.UseNavigation(); var dashQuery = UseQuery( key: 0, - fetcher: async (_, ct) => await LoadDashboardPageAsync(factory, ct), - options: new QueryOptions { KeepPrevious = true, RevalidateOnMount = true }, + fetcher: async (_, ct) => + { + try + { + return await LoadDashboardPageAsync(factory, ct); + } + catch (Exception ex) + { + client.Toast($"Could not load dashboard: {ex.Message}"); + return null; + } + }, + options: new QueryOptions { KeepPrevious = true, RevalidateOnMount = false }, tags: ["dashboard-stats"]); if (dashQuery.Loading && dashQuery.Value == null) - return Layout.Vertical().Gap(3).Padding(4).Height(Size.Full()) - | Text.H3("Dashboard") - | Text.Muted("Loading…") - | Layout.Center().Height(Size.Units(40)) | new Icon(Icons.Loader); + return TabLoadingSkeletons.Dashboard(); if (dashQuery.Value == null) - return Layout.Vertical().Gap(3).Padding(4).Height(Size.Full()).Align(Align.Center) + return Layout.Vertical().Height(Size.Full()).Align(Align.Center) | new Icon(Icons.LayoutDashboard) - | Text.H3("No dashboard data yet") + | Text.H3("No statistics yet") | Text.Block( - "Run tests from the Run tab to record results. Statistics and version charts appear after the first completed run.") + "The database has no completed test run with saved results. " + + "Use Run Tests: set an Ivy version, run all questions, wait until the run finishes.") .Muted() - .Width(Size.Fraction(0.5f)); + .Width(Size.Fraction(0.5f)) + | Layout.Horizontal() + | new Button("Open Run Tests", onClick: _ => navigation.Navigate(typeof(RunApp))) + .Primary() + .Icon(Icons.Play) + | new Button("Refresh", onClick: _ => queryService.RevalidateByTag("dashboard-stats")) + .Variant(ButtonVariant.Outline) + .Icon(Icons.RefreshCw); var page = dashQuery.Value; var data = page.Detail; var versionTrend = page.VersionTrend; - var header = Layout.Vertical().Gap(1) - | Text.H3("Dashboard") - | Text.Block($"Ivy {page.IvyVersion} · {page.RunStartedAt.ToLocalTime():g}").Muted(); - // ── Level 1: Summary KPIs with deltas (vs previous Ivy version when possible) ── var rateStr = $"{data.AnswerRate:F1}%"; var rateDelta = data.PrevAnswerRate.HasValue ? FormatDelta(data.AnswerRate - data.PrevAnswerRate.Value, "%", higherIsBetter: true) - : Text.Muted("first version"); + : Text.Muted("no baseline"); var avgMsStr = $"{data.AvgMs} ms"; var avgMsDelta = data.PrevAvgMs.HasValue ? FormatDelta(data.AvgMs - data.PrevAvgMs.Value, "ms", higherIsBetter: false) - : Text.Muted("first version"); + : Text.Muted("no baseline"); var failedCount = data.NoAnswer + data.Errors; - var kpiRow = Layout.Grid().Columns(4).Gap(3).Height(Size.Fit()) + var runVersion = string.IsNullOrWhiteSpace(page.IvyVersion) ? "—" : page.IvyVersion.Trim(); + var kpiRow = Layout.Grid().Columns(5).Height(Size.Fit()) | new Card( - Layout.Vertical().Gap(2).Padding(3) - | Text.H3(rateStr) - | rateDelta - ).Title("Success Rate").Icon(Icons.CircleCheck) + Layout.Vertical() + | (Layout.Horizontal().Align(Align.Left) + | Text.H3(rateStr) + | rateDelta) + ).Title("Answer success").Icon(Icons.CircleCheck) | new Card( - Layout.Vertical().Gap(2).Padding(3) - | Text.H3(avgMsStr) - | avgMsDelta - ).Title("Avg Response").Icon(Icons.Timer) + Layout.Vertical() + | (Layout.Horizontal().Align(Align.Left) + | Text.H3(avgMsStr) + | avgMsDelta) + ).Title("Avg latency").Icon(Icons.Timer) | new Card( - Layout.Vertical().Gap(2).Padding(3) - | Text.H3(failedCount.ToString()) - | Text.Block($"{data.NoAnswer} no answer · {data.Errors} errors").Muted() - ).Title("Failed Questions").Icon(Icons.CircleX) + Layout.Vertical() + | Text.H3(failedCount.ToString("N0")) + ).Title("Failures").Icon(Icons.CircleX) | new Card( - Layout.Vertical().Gap(2).Padding(3) + Layout.Vertical() | Text.H3(data.WorstWidgets.Count > 0 ? data.WorstWidgets[0].Widget : "—") - | Text.Block(data.WorstWidgets.Count > 0 ? $"{data.WorstWidgets[0].AnswerRate:F1}% answer rate" : "").Muted() - ).Title("Weakest Widget").Icon(Icons.Ban); + ).Title("Weakest widget").Icon(Icons.Ban) + | new Card( + Layout.Vertical() + | Text.H3(runVersion) + ).Title("Ivy version").Icon(Icons.Tag); // ── Version history (one point = latest completed run per Ivy version) ── object versionChartsRow; @@ -89,7 +109,7 @@ public class DashboardApp : ViewBase .Measure("No answer", x => x.Sum(f => f.NoAnswer)) .Measure("Error", x => x.Sum(f => f.Errors)); - versionChartsRow = Layout.Grid().Columns(3).Gap(3).Height(Size.Fit()) + versionChartsRow = Layout.Grid().Columns(3).Height(Size.Fit()) | new Card(rateByVersion).Title("Success rate by Ivy version").Height(Size.Units(70)) | new Card(latencyByVersion).Title("Avg response by Ivy version").Height(Size.Units(70)) | new Card(outcomesByVersion).Title("Outcomes by Ivy version").Height(Size.Units(70)); @@ -147,12 +167,12 @@ public class DashboardApp : ViewBase .Measure("No answer", x => x.Sum(f => f.NoAnswer)) .Measure("Error", x => x.Sum(f => f.Errors)); - var chartsRow = Layout.Grid().Columns(3).Gap(3).Height(Size.Fit()) + var chartsRow = Layout.Grid().Columns(3).Height(Size.Fit()) | new Card(worstChart).Title("Worst Widgets").Height(Size.Units(70)) | new Card(difficultyChart).Title("Results by Difficulty").Height(Size.Units(70)) | new Card(pieChart).Title("Result Distribution").Height(Size.Units(70)); - var problemRow = Layout.Grid().Columns(2).Gap(3).Height(Size.Fit()) + var problemRow = Layout.Grid().Columns(2).Height(Size.Fit()) | new Card(worstTable).Title("Worst Widgets (top 10)") | new Card(slowestTable).Title("Slowest Widgets (top 10)"); @@ -178,8 +198,7 @@ public class DashboardApp : ViewBase c.ShowIndexColumn = true; }); - return Layout.Vertical().Gap(3).Padding(4).Height(Size.Full()) - | header + return Layout.Vertical().Height(Size.Full()) | kpiRow | versionChartsRow | chartsRow @@ -229,6 +248,7 @@ private static object FormatDelta(double delta, string unit, bool higherIsBetter var trendRunIds = latestRunPerVersion.Select(r => r.Id).ToList(); var trendResults = await ctx.TestResults.AsNoTracking() + .AsSplitQuery() .Include(r => r.Question) .Where(r => trendRunIds.Contains(r.TestRunId)) .ToListAsync(ct); @@ -259,6 +279,7 @@ private static object FormatDelta(double delta, string unit, bool higherIsBetter versionTrend.Sort((a, b) => CompareVersionStrings(a.Version, b.Version)); var latestResults = await ctx.TestResults.AsNoTracking() + .AsSplitQuery() .Include(r => r.Question) .Where(r => r.TestRunId == latestRun.Id) .ToListAsync(ct); diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 0cb40266..0211b9d2 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -198,9 +198,7 @@ static string IdleStatus(WidgetRow r) ).ToList(); if (firstLoad) - return Layout.Center() - | new Icon(Icons.Loader) - | Text.Muted("Loading…"); + return TabLoadingSkeletons.QuestionsTab(); var notGeneratedCount = baseRows.Count(r => r.Easy + r.Medium + r.Hard == 0); @@ -209,7 +207,7 @@ static string IdleStatus(WidgetRow r) { var pct = progress.Total > 0 ? progress.Done * 100 / progress.Total : 0; progressBar = new Callout( - Layout.Vertical().Gap(2) + Layout.Vertical() | Text.Block($"Generating {progress.Done + 1}/{progress.Total}: {progress.CurrentWidget}…") | new Progress(pct).Goal($"{progress.Done}/{progress.Total}"), variant: CalloutVariant.Info); @@ -218,7 +216,7 @@ static string IdleStatus(WidgetRow r) { var failCount = progress.Failed.Count; progressBar = new Callout( - Layout.Horizontal().Gap(2) + Layout.Horizontal() | Text.Block(failCount == 0 ? $"Done! Generated questions for {progress.Done}/{progress.Total} widget(s)." : $"Completed: {progress.Done}/{progress.Total} succeeded. Failed: {string.Join(", ", progress.Failed)}") diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index e26e34c0..5fbb27e8 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -157,11 +157,9 @@ async Task StartRunAsync() } if (firstLoad) - return Layout.Center() - | new Icon(Icons.Loader) - | Text.Muted("Loading questions…"); + return TabLoadingSkeletons.RunTab(); - var controls = Layout.Horizontal().Height(Size.Fit()).Gap(2) + var controls = Layout.Horizontal().Height(Size.Fit()) | ivyVersion.ToTextInput().Placeholder("e.g. v2.4.0").Disabled(running) | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(running) | new Button("Run All", onClick: async _ => await StartRunAsync()) @@ -174,7 +172,7 @@ async Task StartRunAsync() { var inFlight = active.Count; statusBar = new Callout( - Layout.Vertical().Gap(2) + Layout.Vertical() | Text.Block($"Running {done}/{questions.Count} completed, {inFlight} in flight (x{concurrency.Value} parallel)") | new Progress(progressPct).Goal($"{done}/{questions.Count}"), variant: CalloutVariant.Info); @@ -198,24 +196,24 @@ async Task StartRunAsync() if (done > 0) { var rate = Math.Round(success * 100.0 / done, 1); - kpiCards = Layout.Grid().Columns(4).Gap(3).Height(Size.Fit()) + kpiCards = Layout.Grid().Columns(4).Height(Size.Fit()) | new Card( - Layout.Vertical().Gap(2).Padding(3) + Layout.Vertical() | Text.H3($"{rate}%") | Text.Block($"{success} of {done} answered").Muted() ).Title("Answer Rate").Icon(Icons.CircleCheck) | new Card( - Layout.Vertical().Gap(2).Padding(3) + Layout.Vertical() | Text.H3($"{noAnswer}") | Text.Block("no answer").Muted() ).Title("No Answer").Icon(Icons.Ban) | new Card( - Layout.Vertical().Gap(2).Padding(3) + Layout.Vertical() | Text.H3($"{errors}") | Text.Block("failed").Muted() ).Title("Errors").Icon(Icons.CircleX) | new Card( - Layout.Vertical().Gap(2).Padding(3) + Layout.Vertical() | Text.H3($"{avgMs} ms") | Text.Block($"fastest {completedList.Min(r => r.ResponseTimeMs)} ms · slowest {completedList.Max(r => r.ResponseTimeMs)} ms").Muted() ).Title("Avg Response").Icon(Icons.Timer); @@ -252,7 +250,7 @@ async Task StartRunAsync() config.ShowIndexColumn = true; }); - return Layout.Vertical().Gap(3).Height(Size.Full()) + return Layout.Vertical().Height(Size.Full()) | controls | statusBar | kpiCards diff --git a/project-demos/ivy-ask-statistics/Apps/TabLoadingSkeletons.cs b/project-demos/ivy-ask-statistics/Apps/TabLoadingSkeletons.cs new file mode 100644 index 00000000..69d1b67b --- /dev/null +++ b/project-demos/ivy-ask-statistics/Apps/TabLoadingSkeletons.cs @@ -0,0 +1,63 @@ +namespace IvyAskStatistics.Apps; + +/// Lightweight placeholders while queries load. Nested Height(Full) on skeletons was removed +/// because it can thrash layout and look like loading never finishes. +internal static class TabLoadingSkeletons +{ + public static object Dashboard() + { + var kpi = Layout.Grid().Columns(5).Height(Size.Fit()) + | new Skeleton().Height(Size.Units(12)) + | new Skeleton().Height(Size.Units(12)) + | new Skeleton().Height(Size.Units(12)) + | new Skeleton().Height(Size.Units(12)) + | new Skeleton().Height(Size.Units(12)); + + var charts1 = Layout.Grid().Columns(3).Height(Size.Fit()) + | new Skeleton().Height(Size.Units(70)) + | new Skeleton().Height(Size.Units(70)) + | new Skeleton().Height(Size.Units(70)); + + var charts2 = Layout.Grid().Columns(3).Height(Size.Fit()) + | new Skeleton().Height(Size.Units(70)) + | new Skeleton().Height(Size.Units(70)) + | new Skeleton().Height(Size.Units(70)); + + return Layout.Vertical().Height(Size.Fit()) + | new Skeleton().Height(Size.Units(10)).Width(Size.Px(160)) + | new Skeleton().Height(Size.Units(7)).Width(Size.Px(220)) + | kpi + | charts1 + | charts2 + | new Skeleton().Height(Size.Units(36)).Width(Size.Fraction(1f)); + } + + public static object RunTab() + { + return Layout.Vertical().Height(Size.Fit()) + | Layout.Horizontal().Height(Size.Fit()) + | new Skeleton().Height(Size.Units(14)).Width(Size.Px(200)) + | new Skeleton().Height(Size.Units(14)).Width(Size.Px(120)) + | new Skeleton().Height(Size.Units(14)).Width(Size.Px(100)) + | new Skeleton().Height(Size.Units(280)).Width(Size.Fraction(1f)); + } + + public static object QuestionsTab() + { + return Layout.Vertical().Height(Size.Fit()) + | Layout.Horizontal().Height(Size.Fit()) + | new Skeleton().Height(Size.Units(12)).Width(Size.Px(140)) + | new Skeleton().Height(Size.Units(12)).Width(Size.Px(140)) + | new Skeleton().Height(Size.Units(12)).Width(Size.Px(140)) + | new Skeleton().Height(Size.Units(280)).Width(Size.Fraction(1f)); + } + + public static object DialogTable() + { + return Layout.Vertical().Width(Size.Fraction(1f)).Height(Size.Fit()) + | new Skeleton().Height(Size.Units(10)).Width(Size.Fraction(1f)) + | new Skeleton().Height(Size.Units(10)).Width(Size.Fraction(1f)) + | new Skeleton().Height(Size.Units(10)).Width(Size.Fraction(1f)) + | new Skeleton().Height(Size.Units(80)).Width(Size.Fraction(1f)); + } +} diff --git a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs index b8dd3fcb..e4fcfc22 100644 --- a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs +++ b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs @@ -56,11 +56,7 @@ async Task DeleteAsync(Guid id) object body; if (firstLoad) - { - body = Layout.Center() - | new Icon(Icons.Loader) - | Text.Muted("Loading…"); - } + body = TabLoadingSkeletons.DialogTable(); else if (rows.Count == 0) { body = new Callout( From f71cd1a28ddd3a06256b244cea3b7104f91ac682 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sat, 4 Apr 2026 21:04:44 +0300 Subject: [PATCH 22/39] refactor: Update Ivy and Ivy.Analyser package versions to 1.2.34 and improve layout alignment in DashboardApp and RunApp for better UI consistency --- project-demos/ivy-ask-statistics/Apps/DashboardApp.cs | 6 +++--- project-demos/ivy-ask-statistics/Apps/RunApp.cs | 2 +- project-demos/ivy-ask-statistics/IvyAskStatistics.csproj | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index d122b109..5cc6533e 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -31,7 +31,7 @@ public class DashboardApp : ViewBase return TabLoadingSkeletons.Dashboard(); if (dashQuery.Value == null) - return Layout.Vertical().Height(Size.Full()).Align(Align.Center) + return Layout.Vertical().Height(Size.Full()).AlignContent(Align.Center) | new Icon(Icons.LayoutDashboard) | Text.H3("No statistics yet") | Text.Block( @@ -68,13 +68,13 @@ public class DashboardApp : ViewBase var kpiRow = Layout.Grid().Columns(5).Height(Size.Fit()) | new Card( Layout.Vertical() - | (Layout.Horizontal().Align(Align.Left) + | (Layout.Horizontal().AlignContent(Align.Left) | Text.H3(rateStr) | rateDelta) ).Title("Answer success").Icon(Icons.CircleCheck) | new Card( Layout.Vertical() - | (Layout.Horizontal().Align(Align.Left) + | (Layout.Horizontal().AlignContent(Align.Left) | Text.H3(avgMsStr) | avgMsDelta) ).Title("Avg latency").Icon(Icons.Timer) diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index 5fbb27e8..cb6037e4 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -236,7 +236,7 @@ async Task StartRunAsync() .Header(r => r.Status, "Status") .Header(r => r.Time, "Time") .Width(r => r.ResultIcon, Size.Px(50)) - .Align(r => r.ResultIcon, Align.Center) + .AlignContent(r => r.ResultIcon, Align.Center) .Width(r => r.Widget, Size.Px(140)) .Width(r => r.Question, Size.Px(400)) .Width(r => r.Difficulty, Size.Px(80)) diff --git a/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj index 68f1f700..f23d08d4 100644 --- a/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj +++ b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj @@ -11,12 +11,12 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive all From c1bff16017b95c5433d459147b62926d3fd7a924 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sun, 5 Apr 2026 11:52:25 +0300 Subject: [PATCH 23/39] fix: Correct typo in PackageReference for Microsoft.EntityFrameworkCore.Design in IvyAskStatistics.csproj --- project-demos/ivy-ask-statistics/IvyAskStatistics.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj index f23d08d4..6977cff2 100644 --- a/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj +++ b/project-demos/ivy-ask-statistics/IvyAskStatistics.csproj @@ -16,7 +16,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive all From cd0d3cc309a4a72894fbd6f1011ae424e11f4472 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sun, 5 Apr 2026 12:01:42 +0300 Subject: [PATCH 24/39] refactor: Improve DashboardApp's error handling and state management by preserving the last successful dashboard payload during data fetching, enhancing user experience during transient errors --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index 5cc6533e..2212dd83 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -3,11 +3,16 @@ namespace IvyAskStatistics.Apps; [App(icon: Icons.LayoutDashboard, title: "Dashboard")] public class DashboardApp : ViewBase { + /// + /// Survives tab switches: state is recreated when the view remounts, + /// but we still need the last successful payload so a failed refetch does not wipe the UI. + /// + private static DashboardPageModel? s_lastSuccessfulDashboard; + public override object? Build() { var factory = UseService(); var client = UseService(); - var queryService = UseService(); var navigation = Context.UseNavigation(); var dashQuery = UseQuery( @@ -16,38 +21,38 @@ public class DashboardApp : ViewBase { try { - return await LoadDashboardPageAsync(factory, ct); + var r = await LoadDashboardPageAsync(factory, ct); + s_lastSuccessfulDashboard = r; + return r; + } + catch (OperationCanceledException) + { + return s_lastSuccessfulDashboard; } - catch (Exception ex) + catch (Exception ex) when (!ct.IsCancellationRequested) { client.Toast($"Could not load dashboard: {ex.Message}"); - return null; + return s_lastSuccessfulDashboard; } }, - options: new QueryOptions { KeepPrevious = true, RevalidateOnMount = false }, + options: new QueryOptions { KeepPrevious = true }, tags: ["dashboard-stats"]); - if (dashQuery.Loading && dashQuery.Value == null) + // Prefer live query value; fall back to last success when remounting or on transient errors. + var page = dashQuery.Value ?? s_lastSuccessfulDashboard; + + if (dashQuery.Loading && page == null) return TabLoadingSkeletons.Dashboard(); - if (dashQuery.Value == null) + if (page == null) return Layout.Vertical().Height(Size.Full()).AlignContent(Align.Center) | new Icon(Icons.LayoutDashboard) | Text.H3("No statistics yet") - | Text.Block( - "The database has no completed test run with saved results. " - + "Use Run Tests: set an Ivy version, run all questions, wait until the run finishes.") + | Text.Block("No completed test runs found. Run a test to see dashboard statistics.") .Muted() - .Width(Size.Fraction(0.5f)) - | Layout.Horizontal() - | new Button("Open Run Tests", onClick: _ => navigation.Navigate(typeof(RunApp))) - .Primary() - .Icon(Icons.Play) - | new Button("Refresh", onClick: _ => queryService.RevalidateByTag("dashboard-stats")) - .Variant(ButtonVariant.Outline) - .Icon(Icons.RefreshCw); - - var page = dashQuery.Value; + | new Button("Run Tests", onClick: _ => navigation.Navigate(typeof(RunApp))) + .Primary() + .Icon(Icons.Play); var data = page.Detail; var versionTrend = page.VersionTrend; From 0f3b3a295cee187d1b4b2c9189930b0bf044fd39 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sun, 5 Apr 2026 12:10:32 +0300 Subject: [PATCH 25/39] refactor: Add MCP environment selection and dynamic base URL handling in RunApp to enhance flexibility and user experience during data fetching --- project-demos/ivy-ask-statistics/Apps/RunApp.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index cb6037e4..d753f908 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -8,7 +8,12 @@ public class RunApp : ViewBase { private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; private static readonly string[] ConcurrencyOptions = ["1", "3", "5", "10", "20"]; - private const string BaseUrl = "https://mcp.ivy.app"; + private static readonly string[] McpEnvironmentOptions = ["production", "staging"]; + + private static string McpBaseUrl(string environment) => + environment.Equals("staging", StringComparison.OrdinalIgnoreCase) + ? "https://staging.mcp.ivy.app" + : "https://mcp.ivy.app"; public override object? Build() { @@ -17,6 +22,7 @@ public class RunApp : ViewBase var queryService = UseService(); var ivyVersion = UseState(""); + var mcpEnvironment = UseState("production"); var difficultyFilter = UseState("all"); var concurrency = UseState("20"); var isRunning = UseState(false); @@ -95,6 +101,7 @@ async Task StartRunAsync() refreshToken.Refresh(); var maxParallel = int.TryParse(concurrency.Value, out var c) ? c : 5; + var baseUrl = McpBaseUrl(mcpEnvironment.Value); _ = Task.Run(async () => { @@ -123,7 +130,7 @@ async Task StartRunAsync() try { - var result = await IvyAskService.AskAsync(q, BaseUrl); + var result = await IvyAskService.AskAsync(q, baseUrl); bag.Add(result); } finally @@ -159,8 +166,11 @@ async Task StartRunAsync() if (firstLoad) return TabLoadingSkeletons.RunTab(); + var mcpBaseForUi = McpBaseUrl(mcpEnvironment.Value); + var controls = Layout.Horizontal().Height(Size.Fit()) | ivyVersion.ToTextInput().Placeholder("e.g. v2.4.0").Disabled(running) + | mcpEnvironment.ToSelectInput(McpEnvironmentOptions).Disabled(running) | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(running) | new Button("Run All", onClick: async _ => await StartRunAsync()) .Primary() @@ -173,7 +183,8 @@ async Task StartRunAsync() var inFlight = active.Count; statusBar = new Callout( Layout.Vertical() - | Text.Block($"Running {done}/{questions.Count} completed, {inFlight} in flight (x{concurrency.Value} parallel)") + | Text.Block( + $"Running {done}/{questions.Count} completed, {inFlight} in flight (x{concurrency.Value} parallel) · {mcpBaseForUi}") | new Progress(progressPct).Goal($"{done}/{questions.Count}"), variant: CalloutVariant.Info); } From c5afd36d28c5cdf72eb724b7b2caab7e2ab50b75 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Sun, 5 Apr 2026 12:29:57 +0300 Subject: [PATCH 26/39] refactor: Enhance IvyAskService and RunApp to support dynamic MCP client ID handling, improving API request flexibility and consistency in question fetching --- .../ivy-ask-statistics/Apps/RunApp.cs | 4 +- .../Services/IvyAskService.cs | 63 ++++++++++++++++--- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index d753f908..b49a486e 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -18,6 +18,7 @@ private static string McpBaseUrl(string environment) => public override object? Build() { var factory = UseService(); + var configuration = UseService(); var client = UseService(); var queryService = UseService(); @@ -102,6 +103,7 @@ async Task StartRunAsync() var maxParallel = int.TryParse(concurrency.Value, out var c) ? c : 5; var baseUrl = McpBaseUrl(mcpEnvironment.Value); + var mcpClient = IvyAskService.ResolveMcpClientId(configuration); _ = Task.Run(async () => { @@ -130,7 +132,7 @@ async Task StartRunAsync() try { - var result = await IvyAskService.AskAsync(q, baseUrl); + var result = await IvyAskService.AskAsync(q, baseUrl, mcpClient); bag.Add(result); } finally diff --git a/project-demos/ivy-ask-statistics/Services/IvyAskService.cs b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs index 54c85278..2cfd0257 100644 --- a/project-demos/ivy-ask-statistics/Services/IvyAskService.cs +++ b/project-demos/ivy-ask-statistics/Services/IvyAskService.cs @@ -3,27 +3,68 @@ using System.Text.RegularExpressions; using IvyAskStatistics.Connections; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; namespace IvyAskStatistics.Services; public static class IvyAskService { public const string DefaultMcpBaseUrl = "https://mcp.ivy.app"; + + /// + /// Sent as client= on every MCP HTTP request when no override is provided. + /// Use a stable slug: lowercase, ASCII, hyphens (e.g. ivy-internal, acme-corp-qa). + /// + public const string DefaultMcpClientId = "ivy-internal"; + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(120) }; + /// Builds GET .../questions?question=...&client=... (client is always included). + public static string BuildQuestionsUrl(string baseUrl, string questionOrPrompt, string? client = null) + { + var root = baseUrl.TrimEnd('/'); + var q = Uri.EscapeDataString(questionOrPrompt); + var id = NormalizeMcpClient(client); + return $"{root}/questions?question={q}&client={Uri.EscapeDataString(id)}"; + } + + /// Normalizes override or returns . + public static string NormalizeMcpClient(string? client) + { + var t = (client ?? "").Trim(); + if (t.Length == 0) return DefaultMcpClientId; + if (t.Length > 120) return t[..120]; + return t; + } + + /// + /// MCP client query value from Mcp:ClientId (user secrets, env, appsettings). + /// If unset or blank, uses . + /// + public static string ResolveMcpClientId(IConfiguration configuration) => + NormalizeMcpClient(configuration["Mcp:ClientId"]); + + /// Appends client= to any MCP URL (e.g. /docs, widget doc path). + public static string WithMcpClientQuery(string absoluteOrRootRelativeUrl, string? client = null) + { + var id = NormalizeMcpClient(client); + var url = absoluteOrRootRelativeUrl.Trim(); + var sep = url.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + return $"{url}{sep}client={Uri.EscapeDataString(id)}"; + } + /// /// Sends a question to the IVY Ask API and returns the result with timing. - /// GET {baseUrl}/questions?question={encoded} + /// GET {baseUrl}/questions?question={encoded}&client={client} /// /// Status codes: /// 200 + body → "success" (answer found) /// 404 → "no_answer" (NO_ANSWER_FOUND) /// other → "error" /// - public static async Task AskAsync(TestQuestion question, string baseUrl) + public static async Task AskAsync(TestQuestion question, string baseUrl, string? client = null) { - var encoded = Uri.EscapeDataString(question.Question); - var url = $"{baseUrl}/questions?question={encoded}"; + var url = BuildQuestionsUrl(baseUrl, question.Question, client); var sw = Stopwatch.StartNew(); try @@ -56,9 +97,10 @@ public static async Task AskAsync(TestQuestion question, string bas /// Calls Ivy Ask with a meta-prompt (e.g. “output JSON array of test questions”). /// Same endpoint as normal Ask; the service returns generated text (often JSON). /// - public static async Task FetchAnswerBodyAsync(string baseUrl, string prompt, CancellationToken ct = default) + public static async Task FetchAnswerBodyAsync( + string baseUrl, string prompt, CancellationToken ct = default, string? client = null) { - var url = $"{baseUrl.TrimEnd('/')}/questions?question={Uri.EscapeDataString(prompt)}"; + var url = BuildQuestionsUrl(baseUrl, prompt, client); using var response = await _http.GetAsync(url, ct); if (response.StatusCode != HttpStatusCode.OK) return null; return await response.Content.ReadAsStringAsync(ct); @@ -121,9 +163,9 @@ private static List TakeStrings(JsonElement array, int take) => /// Fetches the full list of Ivy widgets from the docs API. /// Returns widgets only (filters out non-widget topics). /// - public static async Task> GetWidgetsAsync() + public static async Task> GetWidgetsAsync(string? client = null) { - var yaml = await _http.GetStringAsync($"{DefaultMcpBaseUrl}/docs"); + var yaml = await _http.GetStringAsync(WithMcpClientQuery($"{DefaultMcpBaseUrl}/docs", client)); var widgets = new List(); var lines = yaml.Split('\n'); @@ -190,8 +232,9 @@ public static async Task> GetMergedWidgetCatalogAsync( /// /// Fetches the Markdown documentation for a specific widget. /// - public static async Task GetWidgetDocsAsync(string docLink) + public static async Task GetWidgetDocsAsync(string docLink, string? client = null) { - return await _http.GetStringAsync($"{DefaultMcpBaseUrl}/{docLink}"); + var path = docLink.TrimStart('/'); + return await _http.GetStringAsync(WithMcpClientQuery($"{DefaultMcpBaseUrl}/{path}", client)); } } From daa8709589729eed64e699787df2e446d59c34d6 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 6 Apr 2026 12:42:24 +0300 Subject: [PATCH 27/39] refactor: Update QuestionGeneratorService to include ChatModel configuration, enhancing flexibility in question generation and API interactions --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 2 +- .../Services/QuestionGeneratorService.cs | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 0211b9d2..6bb97e4f 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -76,7 +76,7 @@ async Task GenerateOneAsync(IvyWidget widget) { var apiKey = configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey secret is not set."); var baseUrl = configuration["OpenAI:BaseUrl"] ?? throw new InvalidOperationException("OpenAI:BaseUrl secret is not set."); - await QuestionGeneratorService.GenerateAndSaveAsync(widget, factory, apiKey, baseUrl); + await QuestionGeneratorService.GenerateAndSaveAsync(widget, factory, apiKey, baseUrl, configuration); } async Task GenerateWidgetAsync(IvyWidget widget) diff --git a/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs index dd1fa642..c031a0bf 100644 --- a/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs +++ b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs @@ -1,5 +1,6 @@ using IvyAskStatistics.Connections; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using OpenAI; using OpenAI.Chat; using System.ClientModel; @@ -9,10 +10,12 @@ namespace IvyAskStatistics.Services; /// /// Generates test questions by fetching the full widget Markdown documentation /// from mcp.ivy.app and passing it to OpenAI as context. -/// Uses OpenAI:ApiKey and OpenAI:BaseUrl from configuration / user secrets. +/// Uses OpenAI:ApiKey, OpenAI:BaseUrl, and OpenAI:ChatModel from configuration / user secrets. /// public static class QuestionGeneratorService { + public const string ChatModelConfigKey = "OpenAI:ChatModel"; + /// /// Generates 10 questions per difficulty (easy / medium / hard) for a widget /// and saves them to the database, replacing any previously generated ones. @@ -22,6 +25,7 @@ public static async Task GenerateAndSaveAsync( AppDbContextFactory factory, string openAiApiKey, string openAiBaseUrl, + IConfiguration configuration, IProgress? progress = null, CancellationToken ct = default) { @@ -29,7 +33,14 @@ public static async Task GenerateAndSaveAsync( var markdown = await FetchDocsMarkdownAsync(widget, ct); - var chatClient = BuildChatClient(openAiApiKey, openAiBaseUrl); + var model = configuration[ChatModelConfigKey]?.Trim(); + if (string.IsNullOrEmpty(model)) + { + throw new InvalidOperationException( + $"{ChatModelConfigKey} is not set. Run: dotnet user-secrets set \"{ChatModelConfigKey}\" \"\""); + } + + var chatClient = BuildChatClient(openAiApiKey, openAiBaseUrl, model); foreach (var difficulty in new[] { "easy", "medium", "hard" }) { @@ -81,14 +92,14 @@ private static async Task FetchDocsMarkdownAsync(IvyWidget widget, Cance } } - private static ChatClient BuildChatClient(string apiKey, string baseUrl) + private static ChatClient BuildChatClient(string apiKey, string baseUrl, string model) { var options = new OpenAIClientOptions { Endpoint = new Uri(baseUrl.TrimEnd('/')) }; var client = new OpenAIClient(new ApiKeyCredential(apiKey), options); - return client.GetChatClient("gemini-3.1-flash-lite"); + return client.GetChatClient(model); } private static List BuildMessages(IvyWidget widget, string difficulty, string markdown) From 3602638f8f1f3452004768e0a3b653ff352081da Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 6 Apr 2026 13:23:26 +0300 Subject: [PATCH 28/39] refactor: Enhance QuestionsApp and related services to support concurrent widget generation, improve error handling, and streamline OpenAI configuration management --- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 268 ++++++++++++++---- .../Services/GenerateAllBridge.cs | 18 +- .../Services/QuestionGeneratorService.cs | 26 +- 3 files changed, 254 insertions(+), 58 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 6bb97e4f..464914e7 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -1,16 +1,26 @@ using System.Collections.Immutable; +using System.Threading; namespace IvyAskStatistics.Apps; internal sealed record WidgetTableData(List Rows, List Catalog, int QueryKey); -internal sealed record GenProgress(string CurrentWidget, int Done, int Total, List Failed, bool Active); +internal sealed record GenProgress( + string CurrentWidget, + int Done, + int Total, + List Failed, + bool Active, + int MaxParallel = 1); [App(icon: Icons.Database, title: "Questions")] public class QuestionsApp : ViewBase { private const int TableQueryKey = 0; + /// Batch “Generate all” runs this many widgets concurrently (no config). + private const int WidgetGenerationParallelism = 4; + public override object? Build() { var factory = UseService(); @@ -38,13 +48,23 @@ public class QuestionsApp : ViewBase options: new QueryOptions { KeepPrevious = true, RefreshInterval = TimeSpan.FromSeconds(10), RevalidateOnMount = true }, tags: ["widget-summary"]); + // Register flush on every build (fresh showAlert closure). Call Flush after bind so a pending + // request that arrived before this view existed still opens the dialog. Request() also invokes + // the handler so repeat footer clicks work when Navigate does not rebuild (same tab). UseEffect(() => { - if (tableQuery.Value == null) return; - if (!GenerateAllBridge.Consume()) return; - OnGenerateAll(); + void FlushFooterGenerateAll() + { + if (!GenerateAllBridge.Consume()) return; + ShowFooterGenerateAllDialog(); + } + + GenerateAllBridge.SetFlushHandler(FlushFooterGenerateAll); + FlushFooterGenerateAll(); }, EffectTrigger.OnBuild()); + UseEffect(() => new GenerateAllFlushRegistration(), EffectTrigger.OnMount()); + UseEffect(async () => { var widgetName = deleteRequest.Value; @@ -74,8 +94,8 @@ public class QuestionsApp : ViewBase async Task GenerateOneAsync(IvyWidget widget) { - var apiKey = configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey secret is not set."); - var baseUrl = configuration["OpenAI:BaseUrl"] ?? throw new InvalidOperationException("OpenAI:BaseUrl secret is not set."); + var apiKey = configuration[QuestionGeneratorService.ApiKeyConfigKey]!.Trim(); + var baseUrl = configuration[QuestionGeneratorService.BaseUrlConfigKey]!.Trim(); await QuestionGeneratorService.GenerateAndSaveAsync(widget, factory, apiKey, baseUrl, configuration); } @@ -83,16 +103,17 @@ async Task GenerateWidgetAsync(IvyWidget widget) { try { - genProgress.Set(new GenProgress(widget.Name, 0, 1, [], true)); + genProgress.Set(new GenProgress(widget.Name, 0, 1, [], true, 1)); await GenerateOneAsync(widget); var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); tableQuery.Mutator.Mutate(fresh, revalidate: false); - genProgress.Set(new GenProgress(widget.Name, 1, 1, [], false)); + genProgress.Set(new GenProgress(widget.Name, 1, 1, [], false, 1)); } - catch + catch (Exception ex) { - genProgress.Set(new GenProgress(widget.Name, 0, 1, [widget.Name], false)); + client.Toast(ex.Message); + genProgress.Set(new GenProgress(widget.Name, 0, 1, [widget.Name], false, 1)); } finally { @@ -104,44 +125,141 @@ async Task GenerateWidgetAsync(IvyWidget widget) async Task GenerateBatchAsync(List widgets) { const int maxRetries = 2; + var maxParallel = WidgetGenerationParallelism; + var completed = 0; + var failedLock = new object(); var failed = new List(); - var done = 0; - foreach (var widget in widgets) + using var sem = new SemaphoreSlim(maxParallel); + using var uiGate = new SemaphoreSlim(1, 1); + using var tickerCts = new CancellationTokenSource(); + using var ticker = new PeriodicTimer(TimeSpan.FromMilliseconds(800)); + + async Task PushUiFromStateAsync() { - genProgress.Set(new GenProgress(widget.Name, done, widgets.Count, failed, true)); - refreshToken.Refresh(); + await uiGate.WaitAsync(); + try + { + var d = Volatile.Read(ref completed); + List failedCopy; + lock (failedLock) + failedCopy = [..failed]; + genProgress.Set(new GenProgress("", d, widgets.Count, failedCopy, true, maxParallel)); + var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); + tableQuery.Mutator.Mutate(fresh, revalidate: false); + refreshToken.Refresh(); + } + finally + { + uiGate.Release(); + } + } - var success = false; - for (var attempt = 1; attempt <= maxRetries && !success; attempt++) + var uiTickerTask = Task.Run(async () => + { + try { + while (await ticker.WaitForNextTickAsync(tickerCts.Token)) + await PushUiFromStateAsync(); + } + catch (OperationCanceledException) + { + // expected when batch finishes + } + }); + + try + { + await PushUiFromStateAsync(); + + var workerTasks = widgets.Select(async widget => + { + await sem.WaitAsync(); try { - await GenerateOneAsync(widget); - success = true; + var success = false; + Exception? lastEx = null; + for (var attempt = 1; attempt <= maxRetries && !success; attempt++) + { + try + { + await GenerateOneAsync(widget); + success = true; + } + catch (Exception ex) + { + lastEx = ex; + if (attempt < maxRetries) + await Task.Delay(2000); + } + } + + if (success) + Interlocked.Increment(ref completed); + else + { + lock (failedLock) + failed.Add(widget.Name); + if (lastEx != null) + client.Toast($"\"{widget.Name}\": {lastEx.Message}"); + } + + await uiGate.WaitAsync(); + try + { + generatingWidgets.Set(s => s.Remove(widget.Name)); + refreshToken.Refresh(); + } + finally + { + uiGate.Release(); + } } - catch + finally { - if (attempt < maxRetries) - await Task.Delay(2000); + sem.Release(); } - } + }).ToArray(); - if (success) - done++; - else - failed.Add(widget.Name); + await Task.WhenAll(workerTasks); + } + finally + { + tickerCts.Cancel(); + try + { + await uiTickerTask; + } + catch + { + // ignore cancellation teardown + } - generatingWidgets.Set(s => s.Remove(widget.Name)); + ticker.Dispose(); + } - var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); - tableQuery.Mutator.Mutate(fresh, revalidate: false); + await uiGate.WaitAsync(); + try + { + generatingWidgets.Set(_ => ImmutableHashSet.Empty); + List failedCopy; + lock (failedLock) + failedCopy = [..failed]; + genProgress.Set(new GenProgress( + "", + Volatile.Read(ref completed), + widgets.Count, + failedCopy, + false, + maxParallel)); + var finalFresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); + tableQuery.Mutator.Mutate(finalFresh, revalidate: false); refreshToken.Refresh(); } - - generatingWidgets.Set(_ => ImmutableHashSet.Empty); - genProgress.Set(new GenProgress("", done, widgets.Count, failed, false)); - refreshToken.Refresh(); + finally + { + uiGate.Release(); + } } void MarkGenerating(IEnumerable widgetNames) @@ -152,31 +270,61 @@ void MarkGenerating(IEnumerable widgetNames) refreshToken.Refresh(); } - void OnGenerateAll() + void ShowFooterGenerateAllDialog() { - var allWidgets = tableQuery.Value?.Catalog ?? []; - if (allWidgets.Count == 0) return; - - var allRows = tableQuery.Value?.Rows ?? []; - var notGenerated = allWidgets - .Where(w => !allRows.Any(r => r.Widget == w.Name && r.Easy + r.Medium + r.Hard > 0)) - .ToList(); - - if (notGenerated.Count == 0) return; - showAlert( - $"Generate questions for {notGenerated.Count} widget(s) that don't have questions yet?\n\nOpenAI will be called 3 times per widget (easy / medium / hard).", + "Generate questions for all widgets that don't have questions yet?\n\nOpenAI will be called 3 times per widget (easy / medium / hard). The widget list loads after you tap OK; only widgets with no questions are generated.", result => { if (!result.IsOk()) return; - MarkGenerating(notGenerated.Select(w => w.Name)); - genProgress.Set(new GenProgress(notGenerated[0].Name, 0, notGenerated.Count, [], true)); - Task.Run(() => GenerateBatchAsync(notGenerated)); + var cfgErr = QuestionGeneratorService.GetOpenAiConfigurationError(configuration); + if (cfgErr != null) + { + client.Toast(cfgErr); + return; + } + + _ = RunGenerateAllAfterConfirmAsync(); }, "Generate All Questions", AlertButtonSet.OkCancel); } + async Task RunGenerateAllAfterConfirmAsync() + { + try + { + var data = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); + tableQuery.Mutator.Mutate(data, revalidate: false); + refreshToken.Refresh(); + + var allWidgets = data.Catalog; + if (allWidgets.Count == 0) + { + client.Toast("No widgets found. Check MCP docs or your database."); + return; + } + + var notGenerated = allWidgets + .Where(w => !data.Rows.Any(r => r.Widget == w.Name && r.Easy + r.Medium + r.Hard > 0)) + .ToList(); + + if (notGenerated.Count == 0) + { + client.Toast("Every widget already has at least one question."); + return; + } + + MarkGenerating(notGenerated.Select(w => w.Name)); + genProgress.Set(new GenProgress("", 0, notGenerated.Count, [], true, WidgetGenerationParallelism)); + _ = GenerateBatchAsync(notGenerated); + } + catch (Exception ex) + { + client.Toast(ex.Message); + } + } + var generating = generatingWidgets.Value; var baseRows = tableQuery.Value?.Rows ?? []; var catalog = tableQuery.Value?.Catalog ?? []; @@ -198,7 +346,9 @@ static string IdleStatus(WidgetRow r) ).ToList(); if (firstLoad) - return TabLoadingSkeletons.QuestionsTab(); + return Layout.Vertical().Height(Size.Full()) + | alertView + | TabLoadingSkeletons.QuestionsTab(); var notGeneratedCount = baseRows.Count(r => r.Easy + r.Medium + r.Hard == 0); @@ -206,9 +356,14 @@ static string IdleStatus(WidgetRow r) if (progress is { Active: true }) { var pct = progress.Total > 0 ? progress.Done * 100 / progress.Total : 0; + var statusLine = progress.MaxParallel > 1 + ? $"Completed {progress.Done}/{progress.Total} · up to {progress.MaxParallel} widgets in parallel" + : progress.Total == 1 + ? $"Generating questions for {progress.CurrentWidget}…" + : $"Generating {Math.Min(progress.Done + 1, progress.Total)}/{progress.Total}: {progress.CurrentWidget}…"; progressBar = new Callout( Layout.Vertical() - | Text.Block($"Generating {progress.Done + 1}/{progress.Total}: {progress.CurrentWidget}…") + | Text.Block(statusLine) | new Progress(pct).Goal($"{progress.Done}/{progress.Total}"), variant: CalloutVariant.Info); } @@ -281,6 +436,13 @@ static string IdleStatus(WidgetRow r) result => { if (!result.IsOk()) return; + var cfgErr = QuestionGeneratorService.GetOpenAiConfigurationError(configuration); + if (cfgErr != null) + { + client.Toast(cfgErr); + return; + } + MarkGenerating([widget.Name]); _ = GenerateWidgetAsync(widget); }, @@ -395,4 +557,10 @@ private static async Task LoadWidgetTableDataAsync( return new WidgetTableData(rows, catalog, queryKey); } + + /// Clears when the Questions view unmounts. + sealed class GenerateAllFlushRegistration : IDisposable + { + public void Dispose() => GenerateAllBridge.SetFlushHandler(null); + } } diff --git a/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs b/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs index 3fcb8da0..6114c177 100644 --- a/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs +++ b/project-demos/ivy-ask-statistics/Services/GenerateAllBridge.cs @@ -3,8 +3,24 @@ namespace IvyAskStatistics.Apps; public static class GenerateAllBridge { static volatile bool _pending; + static Action? _flushFromQuestionsView; - public static void Request() => _pending = true; + /// + /// Footer: set pending and flush immediately if is already mounted. + /// Otherwise Navigate runs a build that flushes via the registered handler. + /// (Same-tab Navigate is often a no-op with preventDuplicates, so this avoids a missing dialog.) + /// + public static void Request() + { + _pending = true; + _flushFromQuestionsView?.Invoke(); + } + + /// True while a footer request is pending and not yet consumed. + public static bool IsPending => _pending; + + /// Latest flush from OnBuild; cleared on unmount. + public static void SetFlushHandler(Action? handler) => _flushFromQuestionsView = handler; public static bool Consume() { diff --git a/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs index c031a0bf..ec2b0ae2 100644 --- a/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs +++ b/project-demos/ivy-ask-statistics/Services/QuestionGeneratorService.cs @@ -14,8 +14,22 @@ namespace IvyAskStatistics.Services; /// public static class QuestionGeneratorService { + public const string ApiKeyConfigKey = "OpenAI:ApiKey"; + public const string BaseUrlConfigKey = "OpenAI:BaseUrl"; public const string ChatModelConfigKey = "OpenAI:ChatModel"; + /// Returns null if OpenAI settings are present; otherwise a user-facing message. + public static string? GetOpenAiConfigurationError(IConfiguration configuration) + { + if (string.IsNullOrWhiteSpace(configuration[ApiKeyConfigKey])) + return $"{ApiKeyConfigKey} is not set. Run: dotnet user-secrets set \"{ApiKeyConfigKey}\" \"\""; + if (string.IsNullOrWhiteSpace(configuration[BaseUrlConfigKey])) + return $"{BaseUrlConfigKey} is not set. Run: dotnet user-secrets set \"{BaseUrlConfigKey}\" \"\""; + if (string.IsNullOrWhiteSpace(configuration[ChatModelConfigKey]?.Trim())) + return $"{ChatModelConfigKey} is not set. Run: dotnet user-secrets set \"{ChatModelConfigKey}\" \"\""; + return null; + } + /// /// Generates 10 questions per difficulty (easy / medium / hard) for a widget /// and saves them to the database, replacing any previously generated ones. @@ -29,17 +43,15 @@ public static async Task GenerateAndSaveAsync( IProgress? progress = null, CancellationToken ct = default) { + var cfgErr = GetOpenAiConfigurationError(configuration); + if (cfgErr != null) + throw new InvalidOperationException(cfgErr); + progress?.Report($"Fetching docs for {widget.Name}…"); var markdown = await FetchDocsMarkdownAsync(widget, ct); - var model = configuration[ChatModelConfigKey]?.Trim(); - if (string.IsNullOrEmpty(model)) - { - throw new InvalidOperationException( - $"{ChatModelConfigKey} is not set. Run: dotnet user-secrets set \"{ChatModelConfigKey}\" \"\""); - } - + var model = configuration[ChatModelConfigKey]!.Trim(); var chatClient = BuildChatClient(openAiApiKey, openAiBaseUrl, model); foreach (var difficulty in new[] { "easy", "medium", "hard" }) From 365d69f5b3af812df6b985c05be7703276e7a82f Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 6 Apr 2026 13:35:05 +0300 Subject: [PATCH 29/39] refactor: Integrate TestQuestionsQueryTag into query service revalidation across multiple components to enhance data consistency and responsiveness --- .../ivy-ask-statistics/Apps/QuestionEditSheet.cs | 1 + .../ivy-ask-statistics/Apps/QuestionsApp.cs | 7 +++++++ project-demos/ivy-ask-statistics/Apps/RunApp.cs | 13 ++++++++++++- .../Apps/WidgetQuestionsDialog.cs | 1 + 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs index fa06d797..7b5c495d 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs @@ -63,6 +63,7 @@ async Task OnSubmit(EditRequest? request) await ctx.SaveChangesAsync(); queryService.RevalidateByTag(("widget-questions", entity.Widget)); queryService.RevalidateByTag("widget-summary"); + queryService.RevalidateByTag(RunApp.TestQuestionsQueryTag); isOpen.Set(false); } } diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 464914e7..09404813 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -26,6 +26,7 @@ public class QuestionsApp : ViewBase var factory = UseService(); var configuration = UseService(); var client = UseService(); + var queryService = UseService(); var generatingWidgets = UseState(ImmutableHashSet.Empty); var deleteRequest = UseState(null); @@ -81,6 +82,7 @@ void FlushFooterGenerateAll() var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); tableQuery.Mutator.Mutate(fresh, revalidate: false); refreshToken.Refresh(); + queryService.RevalidateByTag(RunApp.TestQuestionsQueryTag); } catch { @@ -108,6 +110,7 @@ async Task GenerateWidgetAsync(IvyWidget widget) var fresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); tableQuery.Mutator.Mutate(fresh, revalidate: false); + queryService.RevalidateByTag(RunApp.TestQuestionsQueryTag); genProgress.Set(new GenProgress(widget.Name, 1, 1, [], false, 1)); } catch (Exception ex) @@ -195,7 +198,10 @@ async Task PushUiFromStateAsync() } if (success) + { Interlocked.Increment(ref completed); + queryService.RevalidateByTag(RunApp.TestQuestionsQueryTag); + } else { lock (failedLock) @@ -255,6 +261,7 @@ async Task PushUiFromStateAsync() var finalFresh = await LoadWidgetTableDataAsync(factory, TableQueryKey, CancellationToken.None); tableQuery.Mutator.Mutate(finalFresh, revalidate: false); refreshToken.Refresh(); + queryService.RevalidateByTag(RunApp.TestQuestionsQueryTag); } finally { diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index b49a486e..7217d584 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -6,6 +6,9 @@ namespace IvyAskStatistics.Apps; [App(icon: Icons.ChartBar, title: "Run Tests")] public class RunApp : ViewBase { + /// Invalidate with when rows in Questions change. + internal const string TestQuestionsQueryTag = "test-questions-db"; + private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; private static readonly string[] ConcurrencyOptions = ["1", "3", "5", "10", "20"]; private static readonly string[] McpEnvironmentOptions = ["production", "staging"]; @@ -49,7 +52,15 @@ private static string McpBaseUrl(string environment) => var result = await LoadQuestionsAsync(factory, difficultyFilter.Value); refreshToken.Refresh(); return result; - }); + }, + tags: [TestQuestionsQueryTag], + options: new QueryOptions { RevalidateOnMount = true, KeepPrevious = true }); + + // Tab may stay mounted in the background; OnMount runs when Run is first shown again after unmount. + UseEffect(() => + { + questionsQuery.Mutator.Revalidate(); + }, EffectTrigger.OnMount()); var running = isRunning.Value; var firstLoad = questionsQuery.Loading && questionsQuery.Value == null && !running; diff --git a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs index e4fcfc22..a75b5cc2 100644 --- a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs +++ b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs @@ -47,6 +47,7 @@ async Task DeleteAsync(Guid id) tableQuery.Mutator.Mutate(updated, revalidate: false); refreshToken.Refresh(); queryService.RevalidateByTag("widget-summary"); + queryService.RevalidateByTag(RunApp.TestQuestionsQueryTag); } catch (Exception ex) { From 65f6d0e13c6573e5ed772b90d56cf34cc43c3a76 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 6 Apr 2026 14:48:54 +0300 Subject: [PATCH 30/39] refactor: Update query keys in DashboardApp, QuestionsApp, and RunApp to use unique string identifiers, enhancing server cache management and preventing data clobbering across components --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 5 +- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 8 +- .../ivy-ask-statistics/Apps/RunApp.cs | 217 ++++++++++++++++-- .../Connections/AppDbContextFactory.cs | 2 + .../Connections/TestRunEntity.cs | 7 + .../ivy-ask-statistics/Models/RunModels.cs | 22 ++ 6 files changed, 241 insertions(+), 20 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index 2212dd83..f6febe71 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -6,6 +6,7 @@ public class DashboardApp : ViewBase /// /// Survives tab switches: state is recreated when the view remounts, /// but we still need the last successful payload so a failed refetch does not wipe the UI. + /// Use a unique string query key (not shared 0 with other apps) so the server cache is not clobbered. /// private static DashboardPageModel? s_lastSuccessfulDashboard; @@ -15,8 +16,8 @@ public class DashboardApp : ViewBase var client = UseService(); var navigation = Context.UseNavigation(); - var dashQuery = UseQuery( - key: 0, + var dashQuery = UseQuery( + key: "dashboard-stats-page", fetcher: async (_, ct) => { try diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index 09404813..a4f946cf 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -3,7 +3,7 @@ namespace IvyAskStatistics.Apps; -internal sealed record WidgetTableData(List Rows, List Catalog, int QueryKey); +internal sealed record WidgetTableData(List Rows, List Catalog, string QueryKey); internal sealed record GenProgress( string CurrentWidget, @@ -16,7 +16,7 @@ internal sealed record GenProgress( [App(icon: Icons.Database, title: "Questions")] public class QuestionsApp : ViewBase { - private const int TableQueryKey = 0; + private const string TableQueryKey = "questions-widget-table"; /// Batch “Generate all” runs this many widgets concurrently (no config). private const int WidgetGenerationParallelism = 4; @@ -38,7 +38,7 @@ public class QuestionsApp : ViewBase var genProgress = UseState(null); var (alertView, showAlert) = UseAlert(); - var tableQuery = UseQuery( + var tableQuery = UseQuery( key: TableQueryKey, fetcher: async (qk, ct) => { @@ -509,7 +509,7 @@ static string IdleStatus(WidgetRow r) private static async Task LoadWidgetTableDataAsync( AppDbContextFactory factory, - int queryKey, + string queryKey, CancellationToken ct) { await using var ctx = factory.CreateDbContext(); diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index 7217d584..85f83a29 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -9,6 +9,16 @@ public class RunApp : ViewBase /// Invalidate with when rows in Questions change. internal const string TestQuestionsQueryTag = "test-questions-db"; + internal const string LastSavedRunQueryTag = "last-saved-run"; + + /// + /// Must differ from other apps' keys (e.g. Dashboard uses its own string). + /// Reusing 0 across tabs overwrote the server query cache and cleared this panel after navigation. + /// + private const string LastSavedRunQueryKey = "run-tests-last-saved-db"; + + private static LastSavedRunSummary? s_lastSuccessfulLastSavedRun; + private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; private static readonly string[] ConcurrencyOptions = ["1", "3", "5", "10", "20"]; private static readonly string[] McpEnvironmentOptions = ["production", "staging"]; @@ -56,11 +66,28 @@ private static string McpBaseUrl(string environment) => tags: [TestQuestionsQueryTag], options: new QueryOptions { RevalidateOnMount = true, KeepPrevious = true }); - // Tab may stay mounted in the background; OnMount runs when Run is first shown again after unmount. - UseEffect(() => - { - questionsQuery.Mutator.Revalidate(); - }, EffectTrigger.OnMount()); + var lastSavedRunQuery = UseQuery( + key: LastSavedRunQueryKey, + fetcher: async (_, ct) => + { + try + { + var r = await LoadLastSavedRunAsync(factory, ct); + s_lastSuccessfulLastSavedRun = r; + refreshToken.Refresh(); + return r; + } + catch (OperationCanceledException) + { + return s_lastSuccessfulLastSavedRun; + } + }, + tags: [LastSavedRunQueryTag], + options: new QueryOptions { KeepPrevious = true, RevalidateOnMount = true }); + + // Do not call Mutator.Revalidate() from EffectTrigger.OnMount here: both queries already use + // RevalidateOnMount, and an extra OnMount revalidate starts a second fetch that cancels the first, + // producing OperationCanceledException + Ivy.QueryService "Fetch failed" warnings. var running = isRunning.Value; var firstLoad = questionsQuery.Loading && questionsQuery.Value == null && !running; @@ -163,9 +190,20 @@ async Task StartRunAsync() var finalResults = OrderResultsLikeSnapshot(snapshot, bag.ToList()); if (shouldPersist) { - var saved = await PersistNewRunAsync(factory, version, snapshot, finalResults, runStartedUtc); + var saved = await PersistNewRunAsync( + factory, + version, + snapshot, + finalResults, + runStartedUtc, + mcpEnvironment.Value, + difficultyFilter.Value, + concurrency.Value); if (saved) + { queryService.RevalidateByTag("dashboard-stats"); + queryService.RevalidateByTag(LastSavedRunQueryTag); + } else client.Toast("Could not save results to the database."); } @@ -176,8 +214,13 @@ async Task StartRunAsync() }); } + var lastSavedEffective = lastSavedRunQuery.Value ?? s_lastSuccessfulLastSavedRun; + object lastSavedRunPanel = BuildLastSavedRunPanel(lastSavedRunQuery, lastSavedEffective); + if (firstLoad) - return TabLoadingSkeletons.RunTab(); + return Layout.Vertical().Height(Size.Full()) + | lastSavedRunPanel + | TabLoadingSkeletons.RunTab(); var mcpBaseForUi = McpBaseUrl(mcpEnvironment.Value); @@ -275,12 +318,101 @@ async Task StartRunAsync() }); return Layout.Vertical().Height(Size.Full()) + | lastSavedRunPanel | controls | statusBar | kpiCards | table; } + private static object BuildLastSavedRunPanel( + QueryResult query, + LastSavedRunSummary? effective) + { + if (query.Loading && effective == null) + { + return new Callout( + Text.Block("Loading last saved run from the database…"), + variant: CalloutVariant.Info); + } + + var s = effective; + if (s == null) + { + return new Callout( + Layout.Vertical() + | Text.Block("No saved test run in the database yet.") + | Text.Muted( + "Results are written only the first time you finish a run for a given Ivy version. Repeat runs for the same version stay in memory until you refresh."), + variant: CalloutVariant.Warning); + } + + var completedLocal = s.CompletedAtUtc?.ToLocalTime().ToString("dd MMM yyyy, HH:mm") ?? "—"; + var wall = s.CompletedAtUtc.HasValue + ? (s.CompletedAtUtc.Value - s.StartedAtUtc).TotalSeconds + : (double?)null; + var wallText = wall is > 0 ? $"~{wall:F0} s wall time" : "—"; + var concurrencyText = string.IsNullOrEmpty(s.Concurrency) ? "—" : $"{s.Concurrency} parallel"; + + var summaryCallout = new Callout( + Layout.Vertical() + | Text.H3("Last saved run") + | Text.Block( + $"Ivy {s.IvyVersion} · {s.Environment} MCP · difficulty: {s.DifficultyFilter} · concurrency: {concurrencyText}") + | Text.Block( + $"Finished {completedLocal} · {wallText} · {s.TotalQuestions} questions · {s.SuccessCount} answered · {s.NoAnswerCount} no answer · {s.ErrorCount} error(s)"), + variant: CalloutVariant.Info); + + if (s.Rows.Count == 0) + return Layout.Vertical().Gap(2) | summaryCallout | Text.Muted("No per-question rows for this run."); + + var tableRows = s.Rows.Select( + (r, i) => new QuestionRow( + $"last-run-{i}", + r.Widget, + r.Difficulty, + r.QuestionPreview, + r.Outcome == "answered" + ? Icons.CircleCheck + : r.Outcome == "no answer" + ? Icons.Ban + : Icons.CircleX, + r.Outcome, + $"{r.ResponseTimeMs} ms")) + .ToList(); + + var detailTable = tableRows.AsQueryable() + .ToDataTable() + .Key("last-saved-run-table") + .Height(Size.Units(240)) + .Hidden(r => r.Id) + .Header(r => r.Widget, "Widget") + .Header(r => r.Difficulty, "Difficulty") + .Header(r => r.Question, "Question") + .Header(r => r.ResultIcon, "Icon") + .Header(r => r.Status, "Outcome") + .Header(r => r.Time, "Time") + .Width(r => r.ResultIcon, Size.Px(50)) + .AlignContent(r => r.ResultIcon, Align.Center) + .Width(r => r.Widget, Size.Px(140)) + .Width(r => r.Question, Size.Px(400)) + .Width(r => r.Difficulty, Size.Px(80)) + .Width(r => r.Status, Size.Px(90)) + .Width(r => r.Time, Size.Px(80)) + .Config(c => + { + c.AllowSorting = true; + c.AllowFiltering = true; + c.ShowSearch = true; + c.ShowIndexColumn = true; + }); + + return Layout.Vertical().Gap(2) + | summaryCallout + | Text.Block("Saved per-question outcomes").Muted() + | detailTable; + } + private static async Task> LoadQuestionsAsync( AppDbContextFactory factory, string difficulty) @@ -331,7 +463,10 @@ private static async Task PersistNewRunAsync( string ivyVersion, IReadOnlyList snapshot, List ordered, - DateTime startedAtUtc) + DateTime startedAtUtc, + string mcpEnvironment, + string difficultyFilter, + string concurrency) { if (ordered.Count != snapshot.Count) return false; @@ -342,13 +477,16 @@ private static async Task PersistNewRunAsync( { var run = new TestRunEntity { - IvyVersion = ivyVersion, + IvyVersion = ivyVersion, + Environment = mcpEnvironment.Trim().ToLowerInvariant() is "staging" ? "staging" : "production", + DifficultyFilter = string.IsNullOrEmpty(difficultyFilter) ? "all" : difficultyFilter, + Concurrency = concurrency ?? "", TotalQuestions = snapshot.Count, - StartedAt = startedAtUtc, - SuccessCount = ordered.Count(r => r.Status == "success"), - NoAnswerCount = ordered.Count(r => r.Status == "no_answer"), - ErrorCount = ordered.Count(r => r.Status == "error"), - CompletedAt = DateTime.UtcNow + StartedAt = startedAtUtc, + SuccessCount = ordered.Count(r => r.Status == "success"), + NoAnswerCount = ordered.Count(r => r.Status == "no_answer"), + ErrorCount = ordered.Count(r => r.Status == "error"), + CompletedAt = DateTime.UtcNow }; ctx.TestRuns.Add(run); @@ -389,4 +527,55 @@ private static async Task PersistNewRunAsync( "error" => "error", _ => status.Replace('_', ' ') }; + + private static string PreviewQuestionText(string text, int maxChars) + { + if (string.IsNullOrEmpty(text) || text.Length <= maxChars) + return text; + return text[..maxChars] + "…"; + } + + private static async Task LoadLastSavedRunAsync( + AppDbContextFactory factory, + CancellationToken ct) + { + await using var ctx = factory.CreateDbContext(); + var run = await ctx.TestRuns.AsNoTracking() + .OrderByDescending(r => r.CompletedAt ?? r.StartedAt) + .FirstOrDefaultAsync(ct); + + if (run == null) + return null; + + var rows = await ( + from tr in ctx.TestResults.AsNoTracking() + join q in ctx.Questions.AsNoTracking() on tr.QuestionId equals q.Id + where tr.TestRunId == run.Id + orderby q.Widget, q.Difficulty, q.Id + select new LastSavedRunResultRow( + q.Widget, + q.Difficulty, + PreviewQuestionText(q.QuestionText ?? "", 120), + tr.IsSuccess + ? "answered" + : tr.HttpStatus == 404 + ? "no answer" + : "error", + tr.ResponseTimeMs)) + .ToListAsync(ct); + + return new LastSavedRunSummary( + run.Id, + run.IvyVersion, + run.Environment, + string.IsNullOrEmpty(run.DifficultyFilter) ? "all" : run.DifficultyFilter, + run.Concurrency ?? "", + run.TotalQuestions, + run.SuccessCount, + run.NoAnswerCount, + run.ErrorCount, + run.StartedAt, + run.CompletedAt, + rows); + } } diff --git a/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs index f490ea18..09982f69 100644 --- a/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs +++ b/project-demos/ivy-ask-statistics/Connections/AppDbContextFactory.cs @@ -61,6 +61,8 @@ CREATE TABLE IF NOT EXISTS ivy_ask_test_runs ( "CompletedAt" TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS ix_test_runs_ivy_version ON ivy_ask_test_runs ("IvyVersion"); + ALTER TABLE ivy_ask_test_runs ADD COLUMN IF NOT EXISTS "DifficultyFilter" VARCHAR(20) NOT NULL DEFAULT 'all'; + ALTER TABLE ivy_ask_test_runs ADD COLUMN IF NOT EXISTS "Concurrency" VARCHAR(10) NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS ivy_ask_test_results ( "Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/project-demos/ivy-ask-statistics/Connections/TestRunEntity.cs b/project-demos/ivy-ask-statistics/Connections/TestRunEntity.cs index ac9ea7bd..8426f984 100644 --- a/project-demos/ivy-ask-statistics/Connections/TestRunEntity.cs +++ b/project-demos/ivy-ask-statistics/Connections/TestRunEntity.cs @@ -14,6 +14,13 @@ public class TestRunEntity [MaxLength(50)] public string Environment { get; set; } = "production"; + /// Difficulty scope for the run: all, easy, medium, hard. + [MaxLength(20)] + public string DifficultyFilter { get; set; } = "all"; + + [MaxLength(10)] + public string Concurrency { get; set; } = ""; + public int TotalQuestions { get; set; } public int SuccessCount { get; set; } public int NoAnswerCount { get; set; } diff --git a/project-demos/ivy-ask-statistics/Models/RunModels.cs b/project-demos/ivy-ask-statistics/Models/RunModels.cs index 97784299..7abc66ca 100644 --- a/project-demos/ivy-ask-statistics/Models/RunModels.cs +++ b/project-demos/ivy-ask-statistics/Models/RunModels.cs @@ -19,3 +19,25 @@ public record QuestionRow( string Status, string Time ); + +/// Latest row in ivy_ask_test_runs (for Run Tests summary UI). +public sealed record LastSavedRunSummary( + Guid Id, + string IvyVersion, + string Environment, + string DifficultyFilter, + string Concurrency, + int TotalQuestions, + int SuccessCount, + int NoAnswerCount, + int ErrorCount, + DateTime StartedAtUtc, + DateTime? CompletedAtUtc, + IReadOnlyList Rows); + +public sealed record LastSavedRunResultRow( + string Widget, + string Difficulty, + string QuestionPreview, + string Outcome, + int ResponseTimeMs); From 842385dbc0bdb7113334a25591e33f0f2a928866 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 6 Apr 2026 16:58:17 +0300 Subject: [PATCH 31/39] refactor: Enhance RunApp to support fixed parallel Ask requests and improve state management for user preferences, ensuring a more consistent and efficient run experience --- .../ivy-ask-statistics/Apps/RunApp.cs | 434 +++++++++++++----- 1 file changed, 307 insertions(+), 127 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index 85f83a29..9a79ad6b 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -19,8 +19,10 @@ public class RunApp : ViewBase private static LastSavedRunSummary? s_lastSuccessfulLastSavedRun; + /// Fixed parallel Ask requests for every run (not user-configurable). + private const int RunParallelism = 20; + private static readonly string[] DifficultyOptions = ["all", "easy", "medium", "hard"]; - private static readonly string[] ConcurrencyOptions = ["1", "3", "5", "10", "20"]; private static readonly string[] McpEnvironmentOptions = ["production", "staging"]; private static string McpBaseUrl(string environment) => @@ -35,10 +37,9 @@ private static string McpBaseUrl(string environment) => var client = UseService(); var queryService = UseService(); - var ivyVersion = UseState(""); - var mcpEnvironment = UseState("production"); - var difficultyFilter = UseState("all"); - var concurrency = UseState("20"); + var ivyVersion = UseState(RunTestFormPreferences.IvyVersion); + var mcpEnvironment = UseState(RunTestFormPreferences.McpEnvironment); + var difficultyFilter = UseState(RunTestFormPreferences.DifficultyFilter); var isRunning = UseState(false); var completed = UseState>(ImmutableList.Empty); var activeIds = UseState(ImmutableHashSet.Empty); @@ -46,6 +47,7 @@ private static string McpBaseUrl(string environment) => var persistToDb = UseState(false); var refreshToken = UseRefreshToken(); var runFinished = UseState(false); + var runVersionExistsDialogOpen = UseState(false); UseEffect(() => { @@ -55,6 +57,18 @@ private static string McpBaseUrl(string environment) => runFinished.Set(false); }, [difficultyFilter.ToTrigger()]); + UseEffect(() => + { + ivyVersion.Set(RunTestFormPreferences.IvyVersion); + mcpEnvironment.Set(RunTestFormPreferences.McpEnvironment); + difficultyFilter.Set(RunTestFormPreferences.DifficultyFilter); + }, EffectTrigger.OnMount()); + + UseEffect(() => + { + RunTestFormPreferences.Set(ivyVersion.Value, mcpEnvironment.Value, difficultyFilter.Value); + }, [ivyVersion.ToTrigger(), mcpEnvironment.ToTrigger(), difficultyFilter.ToTrigger()]); + var questionsQuery = UseQuery, string>( key: $"questions-{difficultyFilter.Value}", fetcher: async (_, ct) => @@ -85,6 +99,16 @@ private static string McpBaseUrl(string environment) => tags: [LastSavedRunQueryTag], options: new QueryOptions { KeepPrevious = true, RevalidateOnMount = true }); + // Latest ivy_ask_test_runs.IvyVersion when the input is still empty (prefs / first visit). + UseEffect(() => + { + if (lastSavedRunQuery.Loading) return; + var summary = lastSavedRunQuery.Value ?? s_lastSuccessfulLastSavedRun; + if (summary == null || string.IsNullOrWhiteSpace(summary.IvyVersion)) return; + if (!string.IsNullOrWhiteSpace(ivyVersion.Value.Trim())) return; + ivyVersion.Set(summary.IvyVersion.Trim()); + }, EffectTrigger.OnBuild()); + // Do not call Mutator.Revalidate() from EffectTrigger.OnMount here: both queries already use // RevalidateOnMount, and an extra OnMount revalidate starts a second fetch that cancels the first, // producing OperationCanceledException + Ivy.QueryService "Fetch failed" warnings. @@ -102,22 +126,55 @@ private static string McpBaseUrl(string environment) => var avgMs = done > 0 ? (int)completedList.Average(r => r.ResponseTimeMs) : 0; var progressPct = questions.Count > 0 ? done * 100 / questions.Count : 0; - var completedById = completedList.ToDictionary(r => r.Question.Id); - var rows = questions.Select(q => + var lastSavedEffective = lastSavedRunQuery.Value ?? s_lastSuccessfulLastSavedRun; + + List rows; + if (running || done > 0) { - if (completedById.TryGetValue(q.Id, out var r)) + var completedById = completedList.ToDictionary(r => r.Question.Id); + rows = questions.Select(q => { - var icon = r.Status == "success" ? Icons.CircleCheck : Icons.CircleX; - return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, icon, ToStatusLabel(r.Status), $"{r.ResponseTimeMs}ms"); + if (completedById.TryGetValue(q.Id, out var r)) + { + var icon = r.Status == "success" ? Icons.CircleCheck : Icons.CircleX; + return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, icon, ToStatusLabel(r.Status), $"{r.ResponseTimeMs}ms"); + } + + if (active.Contains(q.Id)) + return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, Icons.Loader, "running", ""); + + return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, Icons.Clock, "pending", ""); + }).ToList(); + } + else if (lastSavedEffective?.Rows.Count > 0) + rows = BuildQuestionRowsFromLastSaved(lastSavedEffective); + else + rows = questions.Select(q => + new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, Icons.Clock, "pending", "")) + .ToList(); + + async Task OnRunAllClickedAsync() + { + var snapshot = questionsQuery.Value ?? []; + if (snapshot.Count == 0) return; + + var version = ivyVersion.Value.Trim(); + if (string.IsNullOrEmpty(version)) + { + client.Toast("Please enter an Ivy version before running."); + return; } - if (active.Contains(q.Id)) - return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, Icons.Loader, "running", ""); + if (await RunExistsAsync(factory, version)) + { + runVersionExistsDialogOpen.Set(true); + return; + } - return new QuestionRow(q.Id, q.Widget, q.Difficulty, q.Question, Icons.Clock, "pending", ""); - }).ToList(); + await BeginRunAsync(persistToDatabase: true, replaceExistingRunForVersion: false); + } - async Task StartRunAsync() + async Task BeginRunAsync(bool persistToDatabase, bool replaceExistingRunForVersion) { var snapshot = questionsQuery.Value ?? []; if (snapshot.Count == 0) return; @@ -129,8 +186,7 @@ async Task StartRunAsync() return; } - var shouldPersist = !await RunExistsAsync(factory, version); - persistToDb.Set(shouldPersist); + persistToDb.Set(persistToDatabase); completed.Set(ImmutableList.Empty); activeIds.Set(ImmutableHashSet.Empty); @@ -139,7 +195,7 @@ async Task StartRunAsync() isRunning.Set(true); refreshToken.Refresh(); - var maxParallel = int.TryParse(concurrency.Value, out var c) ? c : 5; + var maxParallel = RunParallelism; var baseUrl = McpBaseUrl(mcpEnvironment.Value); var mcpClient = IvyAskService.ResolveMcpClientId(configuration); @@ -188,7 +244,7 @@ async Task StartRunAsync() activeIds.Set(ImmutableHashSet.Empty); var finalResults = OrderResultsLikeSnapshot(snapshot, bag.ToList()); - if (shouldPersist) + if (persistToDatabase) { var saved = await PersistNewRunAsync( factory, @@ -198,7 +254,8 @@ async Task StartRunAsync() runStartedUtc, mcpEnvironment.Value, difficultyFilter.Value, - concurrency.Value); + RunParallelism.ToString(), + replaceExistingRunForVersion); if (saved) { queryService.RevalidateByTag("dashboard-stats"); @@ -214,25 +271,25 @@ async Task StartRunAsync() }); } - var lastSavedEffective = lastSavedRunQuery.Value ?? s_lastSuccessfulLastSavedRun; object lastSavedRunPanel = BuildLastSavedRunPanel(lastSavedRunQuery, lastSavedEffective); - if (firstLoad) - return Layout.Vertical().Height(Size.Full()) - | lastSavedRunPanel - | TabLoadingSkeletons.RunTab(); - var mcpBaseForUi = McpBaseUrl(mcpEnvironment.Value); - var controls = Layout.Horizontal().Height(Size.Fit()) - | ivyVersion.ToTextInput().Placeholder("e.g. v2.4.0").Disabled(running) + var controls = Layout.Horizontal().Gap(2).Height(Size.Fit()) + | ivyVersion.ToTextInput().Placeholder("Ivy version (e.g. v2.4.0)").Disabled(running) | mcpEnvironment.ToSelectInput(McpEnvironmentOptions).Disabled(running) | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(running) - | new Button("Run All", onClick: async _ => await StartRunAsync()) + | new Button("Run All", onClick: async _ => await OnRunAllClickedAsync()) .Primary() .Icon(Icons.Play) .Disabled(running || questionsQuery.Loading || questions.Count == 0); + if (firstLoad) + return Layout.Vertical().Height(Size.Full()) + | controls + | lastSavedRunPanel + | TabLoadingSkeletons.RunTab(); + object statusBar; if (running) { @@ -240,7 +297,7 @@ async Task StartRunAsync() statusBar = new Callout( Layout.Vertical() | Text.Block( - $"Running {done}/{questions.Count} completed, {inFlight} in flight (x{concurrency.Value} parallel) · {mcpBaseForUi}") + $"Running {done}/{questions.Count} completed, {inFlight} in flight (x{RunParallelism} parallel) · {mcpBaseForUi}") | new Progress(progressPct).Goal($"{done}/{questions.Count}"), variant: CalloutVariant.Info); } @@ -259,41 +316,51 @@ async Task StartRunAsync() statusBar = Text.Muted(""); } - object kpiCards; - if (done > 0) - { - var rate = Math.Round(success * 100.0 / done, 1); - kpiCards = Layout.Grid().Columns(4).Height(Size.Fit()) - | new Card( - Layout.Vertical() - | Text.H3($"{rate}%") - | Text.Block($"{success} of {done} answered").Muted() - ).Title("Answer Rate").Icon(Icons.CircleCheck) - | new Card( - Layout.Vertical() - | Text.H3($"{noAnswer}") - | Text.Block("no answer").Muted() - ).Title("No Answer").Icon(Icons.Ban) - | new Card( - Layout.Vertical() - | Text.H3($"{errors}") - | Text.Block("failed").Muted() - ).Title("Errors").Icon(Icons.CircleX) - | new Card( - Layout.Vertical() - | Text.H3($"{avgMs} ms") - | Text.Block($"fastest {completedList.Min(r => r.ResponseTimeMs)} ms · slowest {completedList.Max(r => r.ResponseTimeMs)} ms").Muted() - ).Title("Avg Response").Icon(Icons.Timer); - } - else - { - kpiCards = Text.Muted(""); - } + object kpiCards = done > 0 + ? BuildOutcomeMetricCards( + done, + success, + noAnswer, + errors, + avgMs, + completedList.Min(r => r.ResponseTimeMs), + completedList.Max(r => r.ResponseTimeMs)) + : Text.Muted(""); + + object liveRunKpiWhileRunning = done > 0 + ? BuildOutcomeMetricCards( + done, + success, + noAnswer, + errors, + avgMs, + completedList.Min(r => r.ResponseTimeMs), + completedList.Max(r => r.ResponseTimeMs)) + : BuildLiveRunMetricPlaceholders(questions.Count); + + // After a finished session, show only this run’s metrics + callout — not the older DB snapshot row. + object historyOrActiveRunPanel = running + ? Layout.Vertical().Gap(2) + | statusBar + | liveRunKpiWhileRunning + : runFinished.Value && done > 0 + ? new Empty() + : lastSavedRunPanel; + + // Completed run: green status + current KPI cards (no "This run" heading; KPIs are not duplicated above). + object afterHistoryPanel = !running && runFinished.Value && done > 0 + ? Layout.Vertical().Gap(2) + | statusBar + | kpiCards + : new Empty(); + + var showingLastSavedSnapshot = !running && done == 0 && lastSavedEffective?.Rows.Count > 0; + var mainTableKey = showingLastSavedSnapshot ? "run-tests-last-saved" : "run-tests-live"; var table = rows.AsQueryable() .ToDataTable() .RefreshToken(refreshToken) - .Key("run-tests-table") + .Key(mainTableKey) .Height(Size.Full()) .Hidden(r => r.Id) .Header(r => r.Widget, "Widget") @@ -317,12 +384,115 @@ async Task StartRunAsync() config.ShowIndexColumn = true; }); - return Layout.Vertical().Height(Size.Full()) - | lastSavedRunPanel - | controls - | statusBar - | kpiCards - | table; + + // Default Vertical gap is 4 (1rem) between every child; avoid empty Text.Muted placeholders that + // still consume gaps. Tight coupling between the metric/header block and the DataTable: gap 0 here. + var runPageTop = Layout.Vertical().Gap(2) + | controls + | historyOrActiveRunPanel; + + var runPageLayout = Layout.Vertical().Height(Size.Full()).Gap(2) + | runPageTop + | afterHistoryPanel + | table; + + object? versionExistsDialog = runVersionExistsDialogOpen.Value + ? new Dialog( + onClose: _ => runVersionExistsDialogOpen.Set(false), + header: new DialogHeader("This Ivy version is already in the database"), + body: new DialogBody( + Text.Block( + $"A completed run for \"{ivyVersion.Value.Trim()}\" is already stored. " + + "You can run tests locally without saving, replace the stored run with new results, or cancel.")), + footer: new DialogFooter( + new Button("Cancel") + .Variant(ButtonVariant.Outline) + .OnClick(_ => runVersionExistsDialogOpen.Set(false)), + new Button("Run without saving") + .OnClick(async _ => + { + runVersionExistsDialogOpen.Set(false); + await BeginRunAsync(persistToDatabase: false, replaceExistingRunForVersion: false); + }), + new Button("Run and replace in database") + .Primary() + .Icon(Icons.Database) + .OnClick(async _ => + { + runVersionExistsDialogOpen.Set(false); + await BeginRunAsync(persistToDatabase: true, replaceExistingRunForVersion: true); + }))) + : null; + + return versionExistsDialog != null + ? new Fragment(runPageLayout, versionExistsDialog) + : runPageLayout; + } + + /// Four metric cards before the first response returns (zeros / placeholders). + private static object BuildLiveRunMetricPlaceholders(int queuedCount) + { + var hint = queuedCount > 0 ? $"{queuedCount} in queue" : "Starting…"; + return Layout.Grid().Columns(4).Height(Size.Fit()) + | new Card( + Layout.Vertical() + | Text.H3("0%") + | Text.Block(hint).Muted() + ).Title("Answer rate").Icon(Icons.CircleCheck) + | new Card( + Layout.Vertical() + | Text.H3("0") + | Text.Block("no answer").Muted() + ).Title("No answer").Icon(Icons.Ban) + | new Card( + Layout.Vertical() + | Text.H3("0") + | Text.Block("failed / error").Muted() + ).Title("Errors").Icon(Icons.CircleX) + | new Card( + Layout.Vertical() + | Text.H3("—") + | Text.Block("waiting for timings…").Muted() + ).Title("Avg response").Icon(Icons.Timer); + } + + /// Same four metric cards as after a live “Run All” completes. + private static object BuildOutcomeMetricCards( + int total, + int success, + int noAnswer, + int errors, + int avgMs, + int minMs, + int maxMs) + { + if (total <= 0) + return Text.Muted(""); + + var rate = Math.Round(success * 100.0 / total, 1); + var timingFooter = $"fastest {minMs} ms · slowest {maxMs} ms"; + + return Layout.Grid().Columns(4).Height(Size.Fit()) + | new Card( + Layout.Vertical() + | Text.H3($"{rate}%") + | Text.Block($"{success} of {total} answered").Muted() + ).Title("Answer rate").Icon(Icons.CircleCheck) + | new Card( + Layout.Vertical() + | Text.H3($"{noAnswer}") + | Text.Block("no answer").Muted() + ).Title("No answer").Icon(Icons.Ban) + | new Card( + Layout.Vertical() + | Text.H3($"{errors}") + | Text.Block("failed / error").Muted() + ).Title("Errors").Icon(Icons.CircleX) + | new Card( + Layout.Vertical() + | Text.H3($"{avgMs} ms") + | Text.Block(timingFooter).Muted() + ).Title("Avg response").Icon(Icons.Timer); } private static object BuildLastSavedRunPanel( @@ -331,44 +501,56 @@ private static object BuildLastSavedRunPanel( { if (query.Loading && effective == null) { - return new Callout( - Text.Block("Loading last saved run from the database…"), - variant: CalloutVariant.Info); + return Layout.Vertical().Gap(2) + | Text.Block("Loading last saved results…").Muted() + | Layout.Grid().Columns(4).Height(Size.Fit()) + | new Skeleton().Height(Size.Units(14)) + | new Skeleton().Height(Size.Units(14)) + | new Skeleton().Height(Size.Units(14)) + | new Skeleton().Height(Size.Units(14)); } var s = effective; if (s == null) { - return new Callout( - Layout.Vertical() - | Text.Block("No saved test run in the database yet.") - | Text.Muted( - "Results are written only the first time you finish a run for a given Ivy version. Repeat runs for the same version stay in memory until you refresh."), - variant: CalloutVariant.Warning); + return Layout.Vertical().Gap(2) + | Text.Block("No saved test run yet.").Muted() + | Text.Muted( + "The first completed run for each Ivy version is stored in the database. Re-runs for the same version stay in this session only until you refresh."); } - var completedLocal = s.CompletedAtUtc?.ToLocalTime().ToString("dd MMM yyyy, HH:mm") ?? "—"; - var wall = s.CompletedAtUtc.HasValue - ? (s.CompletedAtUtc.Value - s.StartedAtUtc).TotalSeconds - : (double?)null; - var wallText = wall is > 0 ? $"~{wall:F0} s wall time" : "—"; - var concurrencyText = string.IsNullOrEmpty(s.Concurrency) ? "—" : $"{s.Concurrency} parallel"; - - var summaryCallout = new Callout( - Layout.Vertical() - | Text.H3("Last saved run") - | Text.Block( - $"Ivy {s.IvyVersion} · {s.Environment} MCP · difficulty: {s.DifficultyFilter} · concurrency: {concurrencyText}") - | Text.Block( - $"Finished {completedLocal} · {wallText} · {s.TotalQuestions} questions · {s.SuccessCount} answered · {s.NoAnswerCount} no answer · {s.ErrorCount} error(s)"), - variant: CalloutVariant.Info); + var times = s.Rows.Select(r => r.ResponseTimeMs).ToList(); + var avgSaved = times.Count > 0 ? (int)times.Average() : 0; + var minSaved = times.Count > 0 ? times.Min() : 0; + var maxSaved = times.Count > 0 ? times.Max() : 0; + var totalForMetrics = s.TotalQuestions > 0 + ? s.TotalQuestions + : Math.Max(s.SuccessCount + s.NoAnswerCount + s.ErrorCount, 1); + + var metricCards = BuildOutcomeMetricCards( + totalForMetrics, + s.SuccessCount, + s.NoAnswerCount, + s.ErrorCount, + avgSaved, + minSaved, + maxSaved); if (s.Rows.Count == 0) - return Layout.Vertical().Gap(2) | summaryCallout | Text.Muted("No per-question rows for this run."); + { + return Layout.Vertical().Gap(3) + | metricCards + | Text.Muted("No per-question rows linked to this run."); + } - var tableRows = s.Rows.Select( + return Layout.Vertical() + | metricCards; + } + + private static List BuildQuestionRowsFromLastSaved(LastSavedRunSummary s) => + s.Rows.Select( (r, i) => new QuestionRow( - $"last-run-{i}", + $"last-saved-{i}", r.Widget, r.Difficulty, r.QuestionPreview, @@ -381,38 +563,6 @@ private static object BuildLastSavedRunPanel( $"{r.ResponseTimeMs} ms")) .ToList(); - var detailTable = tableRows.AsQueryable() - .ToDataTable() - .Key("last-saved-run-table") - .Height(Size.Units(240)) - .Hidden(r => r.Id) - .Header(r => r.Widget, "Widget") - .Header(r => r.Difficulty, "Difficulty") - .Header(r => r.Question, "Question") - .Header(r => r.ResultIcon, "Icon") - .Header(r => r.Status, "Outcome") - .Header(r => r.Time, "Time") - .Width(r => r.ResultIcon, Size.Px(50)) - .AlignContent(r => r.ResultIcon, Align.Center) - .Width(r => r.Widget, Size.Px(140)) - .Width(r => r.Question, Size.Px(400)) - .Width(r => r.Difficulty, Size.Px(80)) - .Width(r => r.Status, Size.Px(90)) - .Width(r => r.Time, Size.Px(80)) - .Config(c => - { - c.AllowSorting = true; - c.AllowFiltering = true; - c.ShowSearch = true; - c.ShowIndexColumn = true; - }); - - return Layout.Vertical().Gap(2) - | summaryCallout - | Text.Block("Saved per-question outcomes").Muted() - | detailTable; - } - private static async Task> LoadQuestionsAsync( AppDbContextFactory factory, string difficulty) @@ -466,7 +616,8 @@ private static async Task PersistNewRunAsync( DateTime startedAtUtc, string mcpEnvironment, string difficultyFilter, - string concurrency) + string concurrency, + bool replaceExistingRunForVersion) { if (ordered.Count != snapshot.Count) return false; @@ -475,6 +626,16 @@ private static async Task PersistNewRunAsync( await using var tx = await ctx.Database.BeginTransactionAsync(); try { + if (replaceExistingRunForVersion) + { + var oldRuns = await ctx.TestRuns.Where(r => r.IvyVersion == ivyVersion).ToListAsync(); + if (oldRuns.Count > 0) + { + ctx.TestRuns.RemoveRange(oldRuns); + await ctx.SaveChangesAsync(); + } + } + var run = new TestRunEntity { IvyVersion = ivyVersion, @@ -579,3 +740,22 @@ join q in ctx.Questions.AsNoTracking() on tr.QuestionId equals q.Id rows); } } + +/// +/// Remembers Run form fields for the current server process so tab switches do not reset MCP / difficulty / version. +/// +file static class RunTestFormPreferences +{ + public static string IvyVersion { get; private set; } = ""; + + public static string McpEnvironment { get; private set; } = "production"; + + public static string DifficultyFilter { get; private set; } = "all"; + + public static void Set(string ivy, string mcp, string diff) + { + IvyVersion = ivy ?? ""; + McpEnvironment = string.IsNullOrEmpty(mcp) ? "production" : mcp; + DifficultyFilter = string.IsNullOrEmpty(diff) ? "all" : diff; + } +} From a8d4bd187a67b1f5b64bf8f735c1cc022de50ef7 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 6 Apr 2026 18:20:59 +0300 Subject: [PATCH 32/39] refactor: Update DashboardApp to enhance KPI display with peer comparison, improve layout alignment, and refine version comparison metrics for better clarity and user experience --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 375 ++++++++++++------ 1 file changed, 257 insertions(+), 118 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index f6febe71..d87dfc37 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -55,70 +55,90 @@ public class DashboardApp : ViewBase .Primary() .Icon(Icons.Play); var data = page.Detail; - var versionTrend = page.VersionTrend; + var peer = page.PeerDetail; + var versionCompare = page.VersionCompare; + var envPrimary = CapitalizeEnv(page.PrimaryEnvironment); + var hasPeerCompare = peer != null && page.PeerEnvironment != null; - // ── Level 1: Summary KPIs with deltas (vs previous Ivy version when possible) ── + // ── Level 1: KPIs (IvyInsights-style headline + delta vs other env or vs previous version) ── var rateStr = $"{data.AnswerRate:F1}%"; - var rateDelta = data.PrevAnswerRate.HasValue - ? FormatDelta(data.AnswerRate - data.PrevAnswerRate.Value, "%", higherIsBetter: true) - : Text.Muted("no baseline"); - - var avgMsStr = $"{data.AvgMs} ms"; - var avgMsDelta = data.PrevAvgMs.HasValue - ? FormatDelta(data.AvgMs - data.PrevAvgMs.Value, "ms", higherIsBetter: false) - : Text.Muted("no baseline"); + object rateDelta = hasPeerCompare + ? FormatDeltaWithTrend(data.AnswerRate - peer!.AnswerRate, "%", higherIsBetter: true) + : data.PrevAnswerRate.HasValue + ? FormatDeltaWithTrend(data.AnswerRate - data.PrevAnswerRate.Value, "%", higherIsBetter: true) + : Text.Muted("no baseline"); + + // NBSP keeps "181" and "ms" on one line; narrow / Fit columns otherwise wrap between tokens. + var avgMsStr = $"{data.AvgMs}\u00A0ms"; + object avgMsDelta = hasPeerCompare + ? FormatDeltaWithTrend(data.AvgMs - peer!.AvgMs, "ms", higherIsBetter: false) + : data.PrevAvgMs.HasValue + ? FormatDeltaWithTrend(data.AvgMs - data.PrevAvgMs.Value, "ms", higherIsBetter: false) + : Text.Muted("no baseline"); var failedCount = data.NoAnswer + data.Errors; + var peerFailed = hasPeerCompare ? peer!.NoAnswer + peer.Errors : (int?)null; + object failedDelta = hasPeerCompare && peerFailed.HasValue + ? FormatDeltaWithTrend(failedCount - peerFailed.Value, "", higherIsBetter: false, countMode: true) + : new Empty(); var runVersion = string.IsNullOrWhiteSpace(page.IvyVersion) ? "—" : page.IvyVersion.Trim(); + var kpiRow = Layout.Grid().Columns(5).Height(Size.Fit()) | new Card( - Layout.Vertical() - | (Layout.Horizontal().AlignContent(Align.Left) - | Text.H3(rateStr) + Layout.Vertical().AlignContent(Align.Center) + | (Layout.Horizontal().AlignContent(Align.Center).Gap(1) + | Text.H2(rateStr).Bold() | rateDelta) ).Title("Answer success").Icon(Icons.CircleCheck) | new Card( - Layout.Vertical() - | (Layout.Horizontal().AlignContent(Align.Left) - | Text.H3(avgMsStr) + Layout.Vertical().AlignContent(Align.Center) + | (Layout.Horizontal().AlignContent(Align.Center).Gap(1) + | Text.H2(avgMsStr).Bold() | avgMsDelta) ).Title("Avg latency").Icon(Icons.Timer) | new Card( - Layout.Vertical() - | Text.H3(failedCount.ToString("N0")) - ).Title("Failures").Icon(Icons.CircleX) + Layout.Vertical().AlignContent(Align.Center) + | (Layout.Horizontal().AlignContent(Align.Center).Gap(1) + | Text.H2(failedCount.ToString("N0")).Bold() + | failedDelta) + ).Title("No answer + errors").Icon(Icons.CircleX) | new Card( - Layout.Vertical() - | Text.H3(data.WorstWidgets.Count > 0 ? data.WorstWidgets[0].Widget : "—") + Layout.Vertical().AlignContent(Align.Center) + | Text.H2(data.WorstWidgets.Count > 0 ? data.WorstWidgets[0].Widget : "—").Bold() ).Title("Weakest widget").Icon(Icons.Ban) | new Card( - Layout.Vertical() - | Text.H3(runVersion) + Layout.Vertical().AlignContent(Align.Center) + | Text.H2(runVersion).Bold() ).Title("Ivy version").Icon(Icons.Tag); - // ── Version history (one point = latest completed run per Ivy version) ── + // ── Production vs staging by Ivy version ── object versionChartsRow; - if (versionTrend.Count >= 1) + if (versionCompare.Count >= 1) { - var rateByVersion = versionTrend.ToBarChart() + var rateByVersion = versionCompare.ToBarChart() .Dimension("Version", x => x.Version) - .Measure("Success %", x => x.Sum(f => f.AnswerRate)); + .Measure("Production %", x => x.Sum(f => f.ProductionAnswerRate)) + .Measure("Staging %", x => x.Sum(f => f.StagingAnswerRate)); - var latencyByVersion = versionTrend.ToBarChart() + var latencyByVersion = versionCompare.ToBarChart() .Dimension("Version", x => x.Version) - .Measure("Avg ms", x => x.Sum(f => f.AvgMs)); + .Measure("Production ms", x => x.Sum(f => f.ProductionAvgMs)) + .Measure("Staging ms", x => x.Sum(f => f.StagingAvgMs)); - var outcomesByVersion = versionTrend.ToBarChart() + var outcomesByVersion = versionCompare.ToBarChart() .Dimension("Version", x => x.Version) - .Measure("Answered", x => x.Sum(f => f.Answered)) - .Measure("No answer", x => x.Sum(f => f.NoAnswer)) - .Measure("Error", x => x.Sum(f => f.Errors)); + .Measure("Prod answered", x => x.Sum(f => f.ProductionAnswered)) + .Measure("Stg answered", x => x.Sum(f => f.StagingAnswered)) + .Measure("Prod no answer", x => x.Sum(f => f.ProductionNoAnswer)) + .Measure("Stg no answer", x => x.Sum(f => f.StagingNoAnswer)) + .Measure("Prod error", x => x.Sum(f => f.ProductionErrors)) + .Measure("Stg error", x => x.Sum(f => f.StagingErrors)); versionChartsRow = Layout.Grid().Columns(3).Height(Size.Fit()) - | new Card(rateByVersion).Title("Success rate by Ivy version").Height(Size.Units(70)) - | new Card(latencyByVersion).Title("Avg response by Ivy version").Height(Size.Units(70)) - | new Card(outcomesByVersion).Title("Outcomes by Ivy version").Height(Size.Units(70)); + | new Card(rateByVersion).Title("Success rate · production vs staging").Height(Size.Units(70)) + | new Card(latencyByVersion).Title("Avg response · production vs staging").Height(Size.Units(70)) + | new Card(outcomesByVersion).Title("Outcomes · production vs staging").Height(Size.Units(70)); } else { @@ -174,13 +194,13 @@ public class DashboardApp : ViewBase .Measure("Error", x => x.Sum(f => f.Errors)); var chartsRow = Layout.Grid().Columns(3).Height(Size.Fit()) - | new Card(worstChart).Title("Worst Widgets").Height(Size.Units(70)) - | new Card(difficultyChart).Title("Results by Difficulty").Height(Size.Units(70)) - | new Card(pieChart).Title("Result Distribution").Height(Size.Units(70)); + | new Card(worstChart).Title($"Worst widgets ({envPrimary})").Height(Size.Units(70)) + | new Card(difficultyChart).Title($"Results by difficulty ({envPrimary})").Height(Size.Units(70)) + | new Card(pieChart).Title($"Result mix ({envPrimary})").Height(Size.Units(70)); var problemRow = Layout.Grid().Columns(2).Height(Size.Fit()) - | new Card(worstTable).Title("Worst Widgets (top 10)") - | new Card(slowestTable).Title("Slowest Widgets (top 10)"); + | new Card(worstTable).Title($"Worst widgets — top 10 ({envPrimary})") + | new Card(slowestTable).Title($"Slowest widgets — top 10 ({envPrimary})"); // ── Level 3: Failed questions debug table ── var failedTable = data.FailedQuestions.AsQueryable() @@ -209,16 +229,43 @@ public class DashboardApp : ViewBase | versionChartsRow | chartsRow | problemRow - | new Card(failedTable).Title($"Failed Questions ({failedCount})"); + | new Card(failedTable).Title($"Failed questions ({failedCount}) · {envPrimary}"); + } + + private static string CapitalizeEnv(string env) => + env.Equals("staging", StringComparison.OrdinalIgnoreCase) ? "Staging" : "Production"; + + private static string NormalizeEnvironment(string? environment) + { + var e = (environment ?? "").Trim().ToLowerInvariant(); + return e == "staging" ? "staging" : "production"; } - private static object FormatDelta(double delta, string unit, bool higherIsBetter) + /// Trend icon + colored delta (same idea as IvyInsights KPI cards). + private static object FormatDeltaWithTrend(double delta, string unit, bool higherIsBetter, bool countMode = false) { - if (Math.Abs(delta) < 0.05) return Text.Muted("no change"); + if (countMode) + { + if (delta == 0) return Text.Muted("—"); + } + else if (unit == "%") + { + if (Math.Abs(delta) < 0.05) return Text.Muted("—"); + } + else if (Math.Abs(delta) < 1) return Text.Muted("—"); + var sign = delta > 0 ? "+" : ""; - var label = unit == "%" ? $"{sign}{delta:F1}{unit}" : $"{sign}{(int)delta} {unit}"; + var label = countMode + ? $"{sign}{(int)delta}" + : unit == "%" + ? $"{sign}{delta:F1}{unit}" + : $"{sign}{(int)delta} {unit}"; var isGood = higherIsBetter ? delta > 0 : delta < 0; - return isGood ? Text.Block(label).Color(Colors.Emerald) : Text.Block(label).Color(Colors.Red); + var icon = isGood ? Icons.TrendingUp : Icons.TrendingDown; + var color = isGood ? Colors.Success : Colors.Destructive; + return Layout.Horizontal().Gap(1).AlignContent(Align.Center) + | new Icon(icon).Color(color) + | Text.H3(label).Color(color); } private static async Task LoadDashboardPageAsync( @@ -240,101 +287,186 @@ private static object FormatDelta(double delta, string unit, bool higherIsBetter if (runs.Count == 0) return null; - var latestRun = runs[0]; + var latestProd = runs.FirstOrDefault(r => NormalizeEnvironment(r.Environment) == "production"); + var latestStag = runs.FirstOrDefault(r => NormalizeEnvironment(r.Environment) == "staging"); + var primaryRun = latestProd ?? latestStag ?? runs[0]; + var primaryEnv = NormalizeEnvironment(primaryRun.Environment); - var seenVersions = new HashSet(StringComparer.OrdinalIgnoreCase); - var latestRunPerVersion = new List(); + var latestProdByVersion = new Dictionary(StringComparer.OrdinalIgnoreCase); + var latestStagByVersion = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var r in runs) { var v = (r.IvyVersion ?? "").Trim(); if (string.IsNullOrEmpty(v)) continue; - if (!seenVersions.Add(v)) continue; - latestRunPerVersion.Add(r); + var dict = NormalizeEnvironment(r.Environment) == "staging" ? latestStagByVersion : latestProdByVersion; + if (!dict.ContainsKey(v)) + dict[v] = r; } - var trendRunIds = latestRunPerVersion.Select(r => r.Id).ToList(); - var trendResults = await ctx.TestResults.AsNoTracking() - .AsSplitQuery() - .Include(r => r.Question) - .Where(r => trendRunIds.Contains(r.TestRunId)) - .ToListAsync(ct); + var allVersions = latestProdByVersion.Keys + .Union(latestStagByVersion.Keys, StringComparer.OrdinalIgnoreCase) + .ToList(); + allVersions.Sort(CompareVersionStrings); + + var compareRunIds = allVersions + .SelectMany(v => new[] + { + latestProdByVersion.TryGetValue(v, out var p) ? p.Id : (Guid?)null, + latestStagByVersion.TryGetValue(v, out var s) ? s.Id : (Guid?)null + }) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + var avgMsByRunId = compareRunIds.Count == 0 + ? new Dictionary() + : await ctx.TestResults.AsNoTracking() + .Where(r => compareRunIds.Contains(r.TestRunId)) + .GroupBy(r => r.TestRunId) + .Select(g => new { g.Key, Avg = g.Average(x => (double)x.ResponseTimeMs) }) + .ToDictionaryAsync(x => x.Key, x => Math.Round(x.Avg, 0), ct); + + static void FillMetrics( + TestRunEntity? run, + IReadOnlyDictionary avgByRun, + out double rate, + out double avgMs, + out int answered, + out int noAnswer, + out int errors) + { + if (run == null) + { + rate = 0; + avgMs = 0; + answered = 0; + noAnswer = 0; + errors = 0; + return; + } - // Use TestRun row counters for outcomes/rate so charts match ivy_ask_test_runs (FinalizeRun totals). - // Row-level ivy_ask_test_results can be short if historical runs failed mid-persist; averages still use rows when present. - var versionTrend = new List(); - foreach (var r in latestRunPerVersion) + var t = run.TotalQuestions; + answered = run.SuccessCount; + noAnswer = run.NoAnswerCount; + errors = run.ErrorCount; + rate = t > 0 ? Math.Round(answered * 100.0 / t, 1) : 0; + avgMs = avgByRun.TryGetValue(run.Id, out var a) ? a : 0; + } + + var versionCompare = new List(); + foreach (var v in allVersions) + { + latestProdByVersion.TryGetValue(v, out var pr); + latestStagByVersion.TryGetValue(v, out var sr); + FillMetrics(pr, avgMsByRunId, out var pRate, out var pAvg, out var pAns, out var pNa, out var pErr); + FillMetrics(sr, avgMsByRunId, out var sRate, out var sAvg, out var sAns, out var sNa, out var sErr); + versionCompare.Add(new VersionCompareRow( + v, pRate, sRate, pAvg, sAvg, pAns, sAns, pNa, sNa, pErr, sErr)); + } + + var currentV = (primaryRun.IvyVersion ?? "").Trim(); + latestStagByVersion.TryGetValue(currentV, out var stagingForVersion); + latestProdByVersion.TryGetValue(currentV, out var productionForVersion); + + TestRunEntity? peerRun = null; + string? peerEnv = null; + if (primaryEnv == "production" && stagingForVersion != null) { - var res = trendResults.Where(x => x.TestRunId == r.Id).ToList(); - var answered = r.SuccessCount; - var noAnswer = r.NoAnswerCount; - var errors = r.ErrorCount; - var t = r.TotalQuestions; - var rate = t > 0 ? Math.Round(answered * 100.0 / t, 1) : 0; - var avgMs = res.Count > 0 ? Math.Round(res.Average(x => (double)x.ResponseTimeMs), 0) : 0; - versionTrend.Add(new VersionTrendRow( - (r.IvyVersion ?? "").Trim(), - rate, - avgMs, - answered, - noAnswer, - errors, - t, - r.StartedAt)); + peerRun = stagingForVersion; + peerEnv = "staging"; + } + else if (primaryEnv == "staging" && productionForVersion != null) + { + peerRun = productionForVersion; + peerEnv = "production"; } - versionTrend.Sort((a, b) => CompareVersionStrings(a.Version, b.Version)); + var trendDict = primaryEnv == "staging" ? latestStagByVersion : latestProdByVersion; + var versionTrendPrimary = trendDict.Values + .Select(r => + { + var t = r.TotalQuestions; + var ans = r.SuccessCount; + var rate = t > 0 ? Math.Round(ans * 100.0 / t, 1) : 0.0; + var avg = avgMsByRunId.TryGetValue(r.Id, out var a) ? (int)Math.Round(a) : 0; + return (Version: (r.IvyVersion ?? "").Trim(), rate, avgMs: avg); + }) + .OrderBy(x => x.Version, Comparer.Create((a, b) => CompareVersionStrings(a, b))) + .ToList(); var latestResults = await ctx.TestResults.AsNoTracking() .AsSplitQuery() .Include(r => r.Question) - .Where(r => r.TestRunId == latestRun.Id) + .Where(r => r.TestRunId == primaryRun.Id) .ToListAsync(ct); - if (latestResults.Count == 0 && (latestRun.CompletedAt == null || latestRun.TotalQuestions == 0)) + if (latestResults.Count == 0 && (primaryRun.CompletedAt == null || primaryRun.TotalQuestions == 0)) return null; double? prevAnswerRate = null; int? prevAvgMs = null; - var currentV = (latestRun.IvyVersion ?? "").Trim(); - var idx = versionTrend.FindIndex(r => string.Equals(r.Version, currentV, StringComparison.OrdinalIgnoreCase)); - if (idx > 0) - { - var prev = versionTrend[idx - 1]; - prevAnswerRate = prev.AnswerRate; - prevAvgMs = (int)prev.AvgMs; - } - else + if (peerRun == null) { - var prevRun = await ctx.TestRuns.AsNoTracking() - .Where(r => r.StartedAt < latestRun.StartedAt && r.CompletedAt != null && runIdsWithData.Contains(r.Id)) - .OrderByDescending(r => r.StartedAt) - .FirstOrDefaultAsync(ct); - if (prevRun != null) + var idx = versionTrendPrimary.FindIndex(r => + string.Equals(r.Version, currentV, StringComparison.OrdinalIgnoreCase)); + if (idx > 0) { - var prevResults = await ctx.TestResults.AsNoTracking() - .Where(r => r.TestRunId == prevRun.Id) - .ToListAsync(ct); - if (prevRun.TotalQuestions > 0 && prevResults.Count == prevRun.TotalQuestions) - { - var prevAns = prevResults.Count(r => r.IsSuccess); - prevAnswerRate = Math.Round(prevAns * 100.0 / prevResults.Count, 1); - prevAvgMs = (int)prevResults.Average(r => r.ResponseTimeMs); - } - else if (prevRun.TotalQuestions > 0) + var prev = versionTrendPrimary[idx - 1]; + prevAnswerRate = prev.rate; + prevAvgMs = prev.avgMs; + } + else + { + var prevRun = await ctx.TestRuns.AsNoTracking() + .Where(r => + r.StartedAt < primaryRun.StartedAt + && r.CompletedAt != null + && runIdsWithData.Contains(r.Id) + && NormalizeEnvironment(r.Environment) == primaryEnv) + .OrderByDescending(r => r.StartedAt) + .FirstOrDefaultAsync(ct); + if (prevRun != null) { - prevAnswerRate = Math.Round(prevRun.SuccessCount * 100.0 / prevRun.TotalQuestions, 1); - prevAvgMs = prevResults.Count > 0 ? (int)prevResults.Average(r => r.ResponseTimeMs) : 0; + var prevResults = await ctx.TestResults.AsNoTracking() + .Where(r => r.TestRunId == prevRun.Id) + .ToListAsync(ct); + if (prevRun.TotalQuestions > 0 && prevResults.Count == prevRun.TotalQuestions) + { + var prevAns = prevResults.Count(r => r.IsSuccess); + prevAnswerRate = Math.Round(prevAns * 100.0 / prevResults.Count, 1); + prevAvgMs = (int)prevResults.Average(r => r.ResponseTimeMs); + } + else if (prevRun.TotalQuestions > 0) + { + prevAnswerRate = Math.Round(prevRun.SuccessCount * 100.0 / prevRun.TotalQuestions, 1); + prevAvgMs = prevResults.Count > 0 ? (int)prevResults.Average(r => r.ResponseTimeMs) : 0; + } } } } - var detail = BuildDashboardData(latestResults, prevAnswerRate, prevAvgMs, latestRun); + var detail = BuildDashboardData(latestResults, prevAnswerRate, prevAvgMs, primaryRun); + + DashboardData? peerDetail = null; + if (peerRun != null) + { + var peerResults = await ctx.TestResults.AsNoTracking() + .AsSplitQuery() + .Include(r => r.Question) + .Where(r => r.TestRunId == peerRun.Id) + .ToListAsync(ct); + peerDetail = BuildDashboardData(peerResults, null, null, peerRun); + } return new DashboardPageModel( currentV, - latestRun.StartedAt, + primaryRun.StartedAt, + primaryEnv, detail, - versionTrend); + peerDetail, + peerEnv, + versionCompare); } private static DashboardData BuildDashboardData( @@ -455,18 +587,25 @@ private static int CompareVersionStrings(string? a, string? b) internal record DashboardPageModel( string IvyVersion, DateTime RunStartedAt, + string PrimaryEnvironment, DashboardData Detail, - List VersionTrend); + DashboardData? PeerDetail, + string? PeerEnvironment, + List VersionCompare); -internal record VersionTrendRow( +/// Per Ivy version: latest production vs latest staging completed runs (0 when an env has no run). +internal record VersionCompareRow( string Version, - double AnswerRate, - double AvgMs, - int Answered, - int NoAnswer, - int Errors, - int Total, - DateTime RunAt); + double ProductionAnswerRate, + double StagingAnswerRate, + double ProductionAvgMs, + double StagingAvgMs, + int ProductionAnswered, + int StagingAnswered, + int ProductionNoAnswer, + int StagingNoAnswer, + int ProductionErrors, + int StagingErrors); internal record DashboardData( int Total, int Answered, int NoAnswer, int Errors, From 629aba465926013e088ee37bef672d6673fc38c8 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 6 Apr 2026 18:36:43 +0300 Subject: [PATCH 33/39] refactor: Enhance RunApp to synchronize Ivy version with MCP environment, improving version handling and user feedback during test runs --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 2 +- .../ivy-ask-statistics/Apps/RunApp.cs | 65 +++++++++++++++++-- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index d87dfc37..ea182176 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -110,7 +110,7 @@ public class DashboardApp : ViewBase | new Card( Layout.Vertical().AlignContent(Align.Center) | Text.H2(runVersion).Bold() - ).Title("Ivy version").Icon(Icons.Tag); + ).Title($"Ivy version ({envPrimary})").Icon(Icons.Tag); // ── Production vs staging by Ivy version ── object versionChartsRow; diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index 9a79ad6b..20a69837 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -69,6 +69,16 @@ private static string McpBaseUrl(string environment) => RunTestFormPreferences.Set(ivyVersion.Value, mcpEnvironment.Value, difficultyFilter.Value); }, [ivyVersion.ToTrigger(), mcpEnvironment.ToTrigger(), difficultyFilter.ToTrigger()]); + UseEffect(() => + { + SyncIvyVersionForMcpEnvironment(ivyVersion, mcpEnvironment.Value); + }, EffectTrigger.OnMount()); + + UseEffect(() => + { + SyncIvyVersionForMcpEnvironment(ivyVersion, mcpEnvironment.Value); + }, [mcpEnvironment.ToTrigger()]); + var questionsQuery = UseQuery, string>( key: $"questions-{difficultyFilter.Value}", fetcher: async (_, ct) => @@ -106,7 +116,7 @@ private static string McpBaseUrl(string environment) => var summary = lastSavedRunQuery.Value ?? s_lastSuccessfulLastSavedRun; if (summary == null || string.IsNullOrWhiteSpace(summary.IvyVersion)) return; if (!string.IsNullOrWhiteSpace(ivyVersion.Value.Trim())) return; - ivyVersion.Set(summary.IvyVersion.Trim()); + ivyVersion.Set(EffectiveIvyVersionForMcp(summary.IvyVersion.Trim(), mcpEnvironment.Value)); }, EffectTrigger.OnBuild()); // Do not call Mutator.Revalidate() from EffectTrigger.OnMount here: both queries already use @@ -158,13 +168,17 @@ async Task OnRunAllClickedAsync() var snapshot = questionsQuery.Value ?? []; if (snapshot.Count == 0) return; - var version = ivyVersion.Value.Trim(); - if (string.IsNullOrEmpty(version)) + var raw = ivyVersion.Value.Trim(); + if (string.IsNullOrEmpty(raw)) { client.Toast("Please enter an Ivy version before running."); return; } + var version = EffectiveIvyVersionForMcp(raw, mcpEnvironment.Value); + if (!string.Equals(ivyVersion.Value.Trim(), version, StringComparison.Ordinal)) + ivyVersion.Set(version); + if (await RunExistsAsync(factory, version)) { runVersionExistsDialogOpen.Set(true); @@ -179,13 +193,17 @@ async Task BeginRunAsync(bool persistToDatabase, bool replaceExistingRunForVersi var snapshot = questionsQuery.Value ?? []; if (snapshot.Count == 0) return; - var version = ivyVersion.Value.Trim(); - if (string.IsNullOrEmpty(version)) + var raw = ivyVersion.Value.Trim(); + if (string.IsNullOrEmpty(raw)) { client.Toast("Please enter an Ivy version before running."); return; } + var version = EffectiveIvyVersionForMcp(raw, mcpEnvironment.Value); + if (!string.Equals(ivyVersion.Value.Trim(), version, StringComparison.Ordinal)) + ivyVersion.Set(version); + persistToDb.Set(persistToDatabase); completed.Set(ImmutableList.Empty); @@ -276,7 +294,9 @@ async Task BeginRunAsync(bool persistToDatabase, bool replaceExistingRunForVersi var mcpBaseForUi = McpBaseUrl(mcpEnvironment.Value); var controls = Layout.Horizontal().Gap(2).Height(Size.Fit()) - | ivyVersion.ToTextInput().Placeholder("Ivy version (e.g. v2.4.0)").Disabled(running) + | ivyVersion.ToTextInput() + .Placeholder("Ivy version (e.g. 1.2.27; staging adds -staging)") + .Disabled(running) | mcpEnvironment.ToSelectInput(McpEnvironmentOptions).Disabled(running) | difficultyFilter.ToSelectInput(DifficultyOptions).Disabled(running) | new Button("Run All", onClick: async _ => await OnRunAllClickedAsync()) @@ -402,7 +422,7 @@ async Task BeginRunAsync(bool persistToDatabase, bool replaceExistingRunForVersi header: new DialogHeader("This Ivy version is already in the database"), body: new DialogBody( Text.Block( - $"A completed run for \"{ivyVersion.Value.Trim()}\" is already stored. " + $"A completed run for \"{EffectiveIvyVersionForMcp(ivyVersion.Value.Trim(), mcpEnvironment.Value)}\" is already stored. " + "You can run tests locally without saving, replace the stored run with new results, or cancel.")), footer: new DialogFooter( new Button("Cancel") @@ -582,6 +602,37 @@ private static async Task> LoadQuestionsAsync( .ToList(); } + private const string IvyStagingVersionSuffix = "-staging"; + + /// + /// Staging runs store a distinct IvyVersion (e.g. 1.2.27-staging) so runs do not collide with production in the DB. + /// Production strips a trailing -staging suffix when switching MCP back. + /// + private static string EffectiveIvyVersionForMcp(string trimmed, string mcpEnvironment) + { + if (string.IsNullOrEmpty(trimmed)) return trimmed; + var useStaging = mcpEnvironment.Equals("staging", StringComparison.OrdinalIgnoreCase); + if (useStaging) + { + if (trimmed.EndsWith(IvyStagingVersionSuffix, StringComparison.OrdinalIgnoreCase)) + return trimmed; + return trimmed + IvyStagingVersionSuffix; + } + + if (trimmed.EndsWith(IvyStagingVersionSuffix, StringComparison.OrdinalIgnoreCase)) + return trimmed[..^IvyStagingVersionSuffix.Length].TrimEnd('-').Trim(); + return trimmed; + } + + private static void SyncIvyVersionForMcpEnvironment(IState ivyVersion, string mcpEnvironment) + { + var raw = ivyVersion.Value.Trim(); + if (string.IsNullOrEmpty(raw)) return; + var next = EffectiveIvyVersionForMcp(raw, mcpEnvironment); + if (!string.Equals(ivyVersion.Value.Trim(), next, StringComparison.Ordinal)) + ivyVersion.Set(next); + } + private static async Task RunExistsAsync(AppDbContextFactory factory, string ivyVersion) { await using var ctx = factory.CreateDbContext(); From ed96e1d3b80dec128fa90b6fe238474954b8b3fe Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Mon, 6 Apr 2026 19:02:24 +0300 Subject: [PATCH 34/39] refactor: Revamp DashboardApp to introduce a detailed Test Runs table and a modal for viewing test results, enhancing data visibility and user interaction --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 193 ++++++++++-------- .../Apps/TestRunResultsDialog.cs | 193 ++++++++++++++++++ 2 files changed, 299 insertions(+), 87 deletions(-) create mode 100644 project-demos/ivy-ask-statistics/Apps/TestRunResultsDialog.cs diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index ea182176..17da5bee 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -12,9 +12,13 @@ public class DashboardApp : ViewBase public override object? Build() { - var factory = UseService(); - var client = UseService(); - var navigation = Context.UseNavigation(); + var factory = UseService(); + var client = UseService(); + var navigation = Context.UseNavigation(); + var selectedRunId = UseState(null); + var runDialogOpen = UseState(false); + var editSheetOpen = UseState(false); + var editQuestionId = UseState(Guid.Empty); var dashQuery = UseQuery( key: "dashboard-stats-page", @@ -145,31 +149,6 @@ public class DashboardApp : ViewBase versionChartsRow = Layout.Vertical(); } - // ── Level 2: Problem tables ── - var worstTable = data.WorstWidgets.AsQueryable() - .ToDataTable(r => r.Widget) - .Key("worst-widgets") - .Header(r => r.Widget, "Widget") - .Header(r => r.AnswerRate, "Rate %") - .Header(r => r.Failed, "Failed") - .Header(r => r.Tested, "Tested") - .Width(r => r.Widget, Size.Px(140)) - .Width(r => r.AnswerRate, Size.Px(70)) - .Width(r => r.Failed, Size.Px(70)) - .Width(r => r.Tested, Size.Px(70)) - .Config(c => { c.ShowIndexColumn = false; c.AllowSorting = true; }); - - var slowestTable = data.SlowestWidgets.AsQueryable() - .ToDataTable(r => r.Widget) - .Key("slowest-widgets") - .Header(r => r.Widget, "Widget") - .Header(r => r.AvgMs, "Avg ms") - .Header(r => r.MaxMs, "Max ms") - .Width(r => r.Widget, Size.Px(140)) - .Width(r => r.AvgMs, Size.Px(80)) - .Width(r => r.MaxMs, Size.Px(80)) - .Config(c => { c.ShowIndexColumn = false; c.AllowSorting = true; }); - var worstChart = data.WorstWidgets.ToBarChart() .Dimension("Widget", x => x.Widget) .Measure("Answer rate %", x => x.Sum(f => f.AnswerRate)); @@ -198,38 +177,69 @@ public class DashboardApp : ViewBase | new Card(difficultyChart).Title($"Results by difficulty ({envPrimary})").Height(Size.Units(70)) | new Card(pieChart).Title($"Result mix ({envPrimary})").Height(Size.Units(70)); - var problemRow = Layout.Grid().Columns(2).Height(Size.Fit()) - | new Card(worstTable).Title($"Worst widgets — top 10 ({envPrimary})") - | new Card(slowestTable).Title($"Slowest widgets — top 10 ({envPrimary})"); - - // ── Level 3: Failed questions debug table ── - var failedTable = data.FailedQuestions.AsQueryable() - .ToDataTable() - .Key("failed-questions") - .Height(Size.Full()) - .Header(r => r.Widget, "Widget") - .Header(r => r.Difficulty, "Difficulty") - .Header(r => r.Question, "Question") - .Header(r => r.Status, "Status") - .Header(r => r.ResponseTimeMs, "Time (ms)") - .Width(r => r.Widget, Size.Px(140)) - .Width(r => r.Difficulty, Size.Px(90)) - .Width(r => r.Status, Size.Px(90)) - .Width(r => r.ResponseTimeMs, Size.Px(90)) + // ── Test runs table (full width) ── + var runsTable = page.AllRuns.AsQueryable() + .ToDataTable(r => r.Id) + .Height(Size.Units(120)) + .Key("all-test-runs") + .Header(r => r.IvyVersion, "Ivy version") + .Header(r => r.Environment, "Environment") + .Header(r => r.DifficultyFilter, "Difficulty") + .Header(r => r.TotalQuestions, "Total") + .Header(r => r.SuccessCount, "Answered") + .Header(r => r.NoAnswerCount, "No answer") + .Header(r => r.ErrorCount, "Errors") + .Header(r => r.AnswerRate, "Rate %") + .Header(r => r.AvgMs, "Avg ms") + .Header(r => r.StartedAt, "Started") + .Header(r => r.CompletedAt, "Completed") + .Width(r => r.IvyVersion, Size.Px(120)) + .Width(r => r.Environment, Size.Px(100)) + .Width(r => r.DifficultyFilter, Size.Px(80)) + .Width(r => r.TotalQuestions, Size.Px(60)) + .Width(r => r.SuccessCount, Size.Px(80)) + .Width(r => r.NoAnswerCount, Size.Px(80)) + .Width(r => r.ErrorCount, Size.Px(60)) + .Width(r => r.AnswerRate, Size.Px(70)) + .Width(r => r.AvgMs, Size.Px(70)) + .Width(r => r.StartedAt, Size.Px(160)) + .Width(r => r.CompletedAt, Size.Px(160)) + .Hidden(r => r.Id) + .RowActions( + MenuItem.Default(Icons.Eye, "view").Label("View results").Tag("view")) + .OnRowAction(e => + { + var args = e.Value; + if (args?.Tag?.ToString() == "view" && Guid.TryParse(args?.Id?.ToString(), out var id)) + { + selectedRunId.Set(id); + runDialogOpen.Set(true); + } + return ValueTask.CompletedTask; + }) .Config(c => { - c.AllowSorting = true; - c.AllowFiltering = true; - c.ShowSearch = true; - c.ShowIndexColumn = true; + c.AllowSorting = true; + c.AllowFiltering = true; + c.ShowSearch = true; + c.ShowIndexColumn = false; }); + var tableRuns = Layout.Vertical() | new Card(runsTable).Title("Test runs"); - return Layout.Vertical().Height(Size.Full()) + var mainLayout = Layout.Vertical().Height(Size.Full()) | kpiRow | versionChartsRow | chartsRow - | problemRow - | new Card(failedTable).Title($"Failed questions ({failedCount}) · {envPrimary}"); + | tableRuns ; + + return new Fragment( + mainLayout, + runDialogOpen.Value && selectedRunId.Value.HasValue + ? new TestRunResultsDialog(runDialogOpen, selectedRunId.Value.Value, editSheetOpen, editQuestionId) + : new Empty(), + editSheetOpen.Value + ? new QuestionEditSheet(editSheetOpen, editQuestionId.Value) + : new Empty()); } private static string CapitalizeEnv(string env) => @@ -308,21 +318,10 @@ private static object FormatDeltaWithTrend(double delta, string unit, bool highe .ToList(); allVersions.Sort(CompareVersionStrings); - var compareRunIds = allVersions - .SelectMany(v => new[] - { - latestProdByVersion.TryGetValue(v, out var p) ? p.Id : (Guid?)null, - latestStagByVersion.TryGetValue(v, out var s) ? s.Id : (Guid?)null - }) - .Where(id => id.HasValue) - .Select(id => id!.Value) - .Distinct() - .ToList(); - - var avgMsByRunId = compareRunIds.Count == 0 + var avgMsByRunId = runIdsWithData.Count == 0 ? new Dictionary() : await ctx.TestResults.AsNoTracking() - .Where(r => compareRunIds.Contains(r.TestRunId)) + .Where(r => runIdsWithData.Contains(r.TestRunId)) .GroupBy(r => r.TestRunId) .Select(g => new { g.Key, Avg = g.Average(x => (double)x.ResponseTimeMs) }) .ToDictionaryAsync(x => x.Key, x => Math.Round(x.Avg, 0), ct); @@ -459,6 +458,26 @@ static void FillMetrics( peerDetail = BuildDashboardData(peerResults, null, null, peerRun); } + var allRuns = runs.Select(r => + { + var t = r.TotalQuestions; + var rate = t > 0 ? Math.Round(r.SuccessCount * 100.0 / t, 1) : 0.0; + var avg = avgMsByRunId.TryGetValue(r.Id, out var a) ? (int)Math.Round(a) : 0; + return new TestRunRow( + r.Id, + r.IvyVersion ?? "", + CapitalizeEnv(r.Environment ?? "production"), + r.DifficultyFilter ?? "all", + r.TotalQuestions, + r.SuccessCount, + r.NoAnswerCount, + r.ErrorCount, + rate, + avg, + r.StartedAt.ToLocalTime(), + r.CompletedAt?.ToLocalTime()); + }).ToList(); + return new DashboardPageModel( currentV, primaryRun.StartedAt, @@ -466,7 +485,8 @@ static void FillMetrics( detail, peerDetail, peerEnv, - versionCompare); + versionCompare, + allRuns); } private static DashboardData BuildDashboardData( @@ -494,7 +514,7 @@ private static DashboardData BuildDashboardData( { return new DashboardData( 0, 0, 0, 0, 0, 0, prevAnswerRate, prevAvgMs, - [], [], [], []); + [], []); } else { @@ -520,7 +540,6 @@ private static DashboardData BuildDashboardData( .ToList(); var worstWidgets = widgetGroups.OrderBy(w => w.AnswerRate).Take(10).ToList(); - var slowestWidgets = widgetGroups.OrderByDescending(w => w.AvgMs).Take(10).ToList(); var diffBreakdown = results .GroupBy(r => r.Question.Difficulty) @@ -536,22 +555,10 @@ private static DashboardData BuildDashboardData( .OrderBy(d => d.Difficulty == "easy" ? 0 : d.Difficulty == "medium" ? 1 : 2) .ToList(); - var failedQuestions = results - .Where(r => !r.IsSuccess) - .OrderBy(r => r.Question.Widget) - .ThenBy(r => r.Question.Difficulty) - .Select(r => new FailedQuestion( - r.Question.Widget, - r.Question.Difficulty, - r.Question.QuestionText, - r.HttpStatus == 404 ? "no answer" : "error", - r.ResponseTimeMs)) - .ToList(); - return new DashboardData( total, answered, noAnswer, errors, answerRate, avgMs, prevAnswerRate, prevAvgMs, - worstWidgets, slowestWidgets, diffBreakdown, failedQuestions); + worstWidgets, diffBreakdown); } /// Semantic-ish ordering so 1.2.26 < 1.2.27 < 1.10.0. @@ -591,7 +598,22 @@ internal record DashboardPageModel( DashboardData Detail, DashboardData? PeerDetail, string? PeerEnvironment, - List VersionCompare); + List VersionCompare, + List AllRuns); + +internal record TestRunRow( + Guid Id, + string IvyVersion, + string Environment, + string DifficultyFilter, + int TotalQuestions, + int SuccessCount, + int NoAnswerCount, + int ErrorCount, + double AnswerRate, + int AvgMs, + DateTime StartedAt, + DateTime? CompletedAt); /// Per Ivy version: latest production vs latest staging completed runs (0 when an env has no run). internal record VersionCompareRow( @@ -612,10 +634,7 @@ internal record DashboardData( double AnswerRate, int AvgMs, double? PrevAnswerRate, int? PrevAvgMs, List WorstWidgets, - List SlowestWidgets, - List DifficultyBreakdown, - List FailedQuestions); + List DifficultyBreakdown); internal record WidgetProblem(string Widget, double AnswerRate, int Failed, int Tested, int AvgMs, int MaxMs); internal record DifficultyRow(string Difficulty, double Rate, int Answered, int NoAnswer, int Errors, int Total); -internal record FailedQuestion(string Widget, string Difficulty, string Question, string Status, int ResponseTimeMs); diff --git a/project-demos/ivy-ask-statistics/Apps/TestRunResultsDialog.cs b/project-demos/ivy-ask-statistics/Apps/TestRunResultsDialog.cs new file mode 100644 index 00000000..78988455 --- /dev/null +++ b/project-demos/ivy-ask-statistics/Apps/TestRunResultsDialog.cs @@ -0,0 +1,193 @@ +namespace IvyAskStatistics.Apps; + +/// Modal listing all test results for a single test run, with edit/delete row actions. +internal sealed class TestRunResultsDialog( + IState isOpen, + Guid runId, + IState editSheetOpen, + IState editQuestionId) : ViewBase +{ + public override object? Build() + { + var factory = UseService(); + var client = UseService(); + var queryService = UseService(); + var refreshToken = UseRefreshToken(); + + var (alertView, showAlert) = UseAlert(); + + var resultsQuery = UseQuery( + key: $"run-results-{runId}", + fetcher: async (_, ct) => + { + var payload = await LoadAsync(factory, runId, ct); + refreshToken.Refresh(); + return payload; + }, + options: new QueryOptions { KeepPrevious = true }, + tags: [("run-results", runId.ToString())]); + + var firstLoad = resultsQuery.Loading && resultsQuery.Value == null; + var rows = resultsQuery.Value?.Results ?? []; + var runInfo = resultsQuery.Value?.RunInfo; + + void Close() => isOpen.Set(false); + + async Task DeleteAsync(Guid resultId) + { + try + { + await using var ctx = factory.CreateDbContext(); + var entity = await ctx.TestResults.FirstOrDefaultAsync(r => r.Id == resultId); + if (entity != null) + { + ctx.TestResults.Remove(entity); + await ctx.SaveChangesAsync(); + + var run = await ctx.TestRuns.FirstOrDefaultAsync(r => r.Id == runId); + if (run != null) + { + var remaining = await ctx.TestResults + .Where(r => r.TestRunId == runId) + .ToListAsync(); + run.TotalQuestions = remaining.Count; + run.SuccessCount = remaining.Count(r => r.IsSuccess); + run.NoAnswerCount = remaining.Count(r => !r.IsSuccess && r.HttpStatus == 404); + run.ErrorCount = remaining.Count(r => !r.IsSuccess && r.HttpStatus != 404); + await ctx.SaveChangesAsync(); + } + } + + var updated = rows.Where(r => r.ResultId != resultId).ToList(); + resultsQuery.Mutator.Mutate(new RunResultsPayload(runInfo, updated), revalidate: false); + refreshToken.Refresh(); + queryService.RevalidateByTag("dashboard-stats"); + } + catch (Exception ex) + { + client.Toast($"Error: {ex.Message}"); + } + } + + object body; + if (firstLoad) + body = TabLoadingSkeletons.DialogTable(); + else if (rows.Count == 0) + body = new Callout("No results found for this run.", variant: CalloutVariant.Info); + else + { + body = rows.AsQueryable() + .ToDataTable(r => r.ResultId) + .Key($"run-results-tbl-{runId}") + .Height(Size.Units(120)) + .RefreshToken(refreshToken) + .Header(r => r.Widget, "Widget") + .Header(r => r.Difficulty, "Difficulty") + .Header(r => r.Question, "Question") + .Header(r => r.Status, "Status") + .Header(r => r.Response, "Response") + .Header(r => r.ResponseTimeMs, "Time (ms)") + .Width(r => r.Widget, Size.Px(130)) + .Width(r => r.Difficulty, Size.Px(80)) + .Width(r => r.Status, Size.Px(90)) + .Width(r => r.ResponseTimeMs, Size.Px(80)) + .Hidden(r => r.ResultId) + .Hidden(r => r.QuestionId) + .RowActions( + MenuItem.Default(Icons.Pencil, "edit").Label("Edit question").Tag("edit"), + MenuItem.Default(Icons.Trash2, "delete").Label("Delete result").Tag("delete")) + .OnRowAction(e => + { + var args = e.Value; + var tag = args?.Tag?.ToString(); + if (!Guid.TryParse(args?.Id?.ToString(), out var resultId)) + return ValueTask.CompletedTask; + + var row = rows.FirstOrDefault(r => r.ResultId == resultId); + if (row == null) return ValueTask.CompletedTask; + + if (tag == "edit") + { + editQuestionId.Set(row.QuestionId); + editSheetOpen.Set(true); + } + else if (tag == "delete") + { + var preview = row.Question.Length > 60 ? row.Question[..60] + "…" : row.Question; + showAlert( + $"Delete this result?\n\n\"{preview}\"", + async result => + { + if (!result.IsOk()) return; + await DeleteAsync(resultId); + }, + "Delete result", + AlertButtonSet.OkCancel); + } + + return ValueTask.CompletedTask; + }) + .Config(c => + { + c.AllowSorting = true; + c.AllowFiltering = true; + c.ShowSearch = true; + c.ShowIndexColumn = false; + }); + } + + var title = firstLoad + ? "Loading results…" + : runInfo != null + ? $"Results — {runInfo.IvyVersion} · {runInfo.Environment} ({rows.Count})" + : $"Results ({rows.Count})"; + + return new Fragment( + alertView, + new Dialog( + onClose: _ => Close(), + header: new DialogHeader(title), + body: new DialogBody(body), + footer: new DialogFooter(new Button("Close").OnClick(_ => Close()))) + .Width(Size.Units(320))); + } + + private static async Task LoadAsync( + AppDbContextFactory factory, Guid runId, CancellationToken ct) + { + await using var ctx = factory.CreateDbContext(); + + var run = await ctx.TestRuns.AsNoTracking().FirstOrDefaultAsync(r => r.Id == runId, ct); + var runInfo = run == null ? null : new RunInfo(run.IvyVersion ?? "", run.Environment ?? "production"); + + var results = await ctx.TestResults.AsNoTracking() + .Include(r => r.Question) + .Where(r => r.TestRunId == runId) + .OrderBy(r => r.Question.Widget) + .ThenBy(r => r.Question.Difficulty) + .Select(r => new RunResultRow( + r.Id, + r.QuestionId, + r.Question.Widget, + r.Question.Difficulty, + r.Question.QuestionText, + r.IsSuccess ? "answered" : r.HttpStatus == 404 ? "no answer" : "error", + r.ResponseText, + r.ResponseTimeMs)) + .ToListAsync(ct); + + return new RunResultsPayload(runInfo, results); + } +} + +internal record RunInfo(string IvyVersion, string Environment); +internal record RunResultsPayload(RunInfo? RunInfo, List Results); +internal record RunResultRow( + Guid ResultId, + Guid QuestionId, + string Widget, + string Difficulty, + string Question, + string Status, + string Response, + int ResponseTimeMs); From 9a5329c2f04f55ca63c309640250a8616990a819 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 7 Apr 2026 00:18:55 +0300 Subject: [PATCH 35/39] refactor: Revise DashboardApp to implement a dictionary for caching dashboard data by query key, improve error handling, and enhance environment data toggling for better user experience --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 231 +++++++++++++++--- 1 file changed, 201 insertions(+), 30 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index 17da5bee..4f3f71fb 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -8,7 +8,7 @@ public class DashboardApp : ViewBase /// but we still need the last successful payload so a failed refetch does not wipe the UI. /// Use a unique string query key (not shared 0 with other apps) so the server cache is not clobbered. /// - private static DashboardPageModel? s_lastSuccessfulDashboard; + private static readonly Dictionary s_lastDashboardByKey = new(StringComparer.Ordinal); public override object? Build() { @@ -19,32 +19,38 @@ public class DashboardApp : ViewBase var runDialogOpen = UseState(false); var editSheetOpen = UseState(false); var editQuestionId = UseState(Guid.Empty); + var envOverride = UseState("production"); + var dashboardFocusVersion = UseState(null); + var versionSheetOpen = UseState(false); var dashQuery = UseQuery( - key: "dashboard-stats-page", - fetcher: async (_, ct) => + key: DashboardQueryKey(dashboardFocusVersion.Value), + fetcher: async (key, ct) => { + var focusVersion = ParseDashboardFocusFromQueryKey(key); try { - var r = await LoadDashboardPageAsync(factory, ct); - s_lastSuccessfulDashboard = r; + var r = await LoadDashboardPageAsync(factory, focusVersion, ct); + s_lastDashboardByKey[key] = r; return r; } catch (OperationCanceledException) { - return s_lastSuccessfulDashboard; + return s_lastDashboardByKey.TryGetValue(key, out var prev) ? prev : null; } catch (Exception ex) when (!ct.IsCancellationRequested) { client.Toast($"Could not load dashboard: {ex.Message}"); - return s_lastSuccessfulDashboard; + return s_lastDashboardByKey.TryGetValue(key, out var prev) ? prev : null; } }, - options: new QueryOptions { KeepPrevious = true }, + options: new QueryOptions { KeepPrevious = false }, tags: ["dashboard-stats"]); - // Prefer live query value; fall back to last success when remounting or on transient errors. - var page = dashQuery.Value ?? s_lastSuccessfulDashboard; + var dashQueryKey = DashboardQueryKey(dashboardFocusVersion.Value); + + // Prefer live query value; fall back to last success for this query key when remounting or on transient errors. + var page = dashQuery.Value ?? (s_lastDashboardByKey.TryGetValue(dashQueryKey, out var cached) ? cached : null); if (dashQuery.Loading && page == null) return TabLoadingSkeletons.Dashboard(); @@ -58,11 +64,41 @@ public class DashboardApp : ViewBase | new Button("Run Tests", onClick: _ => navigation.Navigate(typeof(RunApp))) .Primary() .Icon(Icons.Play); - var data = page.Detail; - var peer = page.PeerDetail; var versionCompare = page.VersionCompare; - var envPrimary = CapitalizeEnv(page.PrimaryEnvironment); - var hasPeerCompare = peer != null && page.PeerEnvironment != null; + + // Resolve data for each env from what the loader returned. + var prodData = string.Equals(page.PrimaryEnvironment, "production", StringComparison.OrdinalIgnoreCase) + ? page.Detail : page.PeerDetail; + var stgData = string.Equals(page.PrimaryEnvironment, "staging", StringComparison.OrdinalIgnoreCase) + ? page.Detail : page.PeerDetail; + var hasStagingData = stgData != null; + var hasProductionData = prodData != null; + + // Respect the user's toggle; fall back gracefully when an env has no data. + var showEnv = envOverride.Value; + if (showEnv == "staging" && !hasStagingData) showEnv = "production"; + if (showEnv == "production" && !hasProductionData) showEnv = "staging"; + + DashboardData data; + DashboardData? peer; + string envPrimary; + if (showEnv == "staging" && stgData != null) + { + data = stgData; + peer = prodData; + envPrimary = "Staging"; + } + else + { + data = prodData ?? page.Detail; + peer = stgData; + envPrimary = "Production"; + } + var hasPeerCompare = peer != null; + + // Remount KPIs + charts when the selected Ivy version or env slice changes. Keep this stable (no live metrics) + // so we do not get a new key every build — that would remount widgets and retrigger CSS animations constantly. + var dashboardVisualKey = $"{dashQueryKey}|{envPrimary}"; // ── Level 1: KPIs (IvyInsights-style headline + delta vs other env or vs previous version) ── var rateStr = $"{data.AnswerRate:F1}%"; @@ -88,7 +124,7 @@ public class DashboardApp : ViewBase var runVersion = string.IsNullOrWhiteSpace(page.IvyVersion) ? "—" : page.IvyVersion.Trim(); - var kpiRow = Layout.Grid().Columns(5).Height(Size.Fit()) + var kpiRow = Layout.Grid().Columns(5).Height(Size.Fit()).Key(dashboardVisualKey + "|kpi") | new Card( Layout.Vertical().AlignContent(Align.Center) | (Layout.Horizontal().AlignContent(Align.Center).Gap(1) @@ -114,7 +150,10 @@ public class DashboardApp : ViewBase | new Card( Layout.Vertical().AlignContent(Align.Center) | Text.H2(runVersion).Bold() - ).Title($"Ivy version ({envPrimary})").Icon(Icons.Tag); + ).Title("Ivy version").Icon(Icons.Tag) + .OnClick(versionCompare.Count > 0 + ? _ => versionSheetOpen.Set(true) + : _ => { }); // ── Production vs staging by Ivy version ── object versionChartsRow; @@ -139,7 +178,7 @@ public class DashboardApp : ViewBase .Measure("Prod error", x => x.Sum(f => f.ProductionErrors)) .Measure("Stg error", x => x.Sum(f => f.StagingErrors)); - versionChartsRow = Layout.Grid().Columns(3).Height(Size.Fit()) + versionChartsRow = Layout.Grid().Columns(3).Height(Size.Fit()).Key(dashboardVisualKey + "|vercmp") | new Card(rateByVersion).Title("Success rate · production vs staging").Height(Size.Units(70)) | new Card(latencyByVersion).Title("Avg response · production vs staging").Height(Size.Units(70)) | new Card(outcomesByVersion).Title("Outcomes · production vs staging").Height(Size.Units(70)); @@ -153,6 +192,11 @@ public class DashboardApp : ViewBase .Dimension("Widget", x => x.Widget) .Measure("Answer rate %", x => x.Sum(f => f.AnswerRate)); + var latencyByWidgetChart = data.WorstWidgets.ToBarChart() + .Dimension("Widget", x => x.Widget) + .Measure("Avg ms", x => x.Sum(f => (double)f.AvgMs)) + .Measure("Max ms", x => x.Sum(f => (double)f.MaxMs)); + var resultDistribution = new[] { new { Label = "Answered", Count = data.Answered }, @@ -172,8 +216,11 @@ public class DashboardApp : ViewBase .Measure("No answer", x => x.Sum(f => f.NoAnswer)) .Measure("Error", x => x.Sum(f => f.Errors)); - var chartsRow = Layout.Grid().Columns(3).Height(Size.Fit()) - | new Card(worstChart).Title($"Worst widgets ({envPrimary})").Height(Size.Units(70)) + // Row 1: 3 charts (prod vs staging by version — always both envs) + // Row 2: 4 charts (per-selected-env diagnostics) + var chartsRow = Layout.Grid().Columns(4).Height(Size.Fit()).Key(dashboardVisualKey + "|detail-charts") + | new Card(worstChart).Title($"Worst widgets — rate % ({envPrimary})").Height(Size.Units(70)) + | new Card(latencyByWidgetChart).Title($"Latency by widget — avg / max ({envPrimary})").Height(Size.Units(70)) | new Card(difficultyChart).Title($"Results by difficulty ({envPrimary})").Height(Size.Units(70)) | new Card(pieChart).Title($"Result mix ({envPrimary})").Height(Size.Units(70)); @@ -181,7 +228,7 @@ public class DashboardApp : ViewBase var runsTable = page.AllRuns.AsQueryable() .ToDataTable(r => r.Id) .Height(Size.Units(120)) - .Key("all-test-runs") + .Key($"all-test-runs|{dashQueryKey}") .Header(r => r.IvyVersion, "Ivy version") .Header(r => r.Environment, "Environment") .Header(r => r.DifficultyFilter, "Difficulty") @@ -234,6 +281,13 @@ public class DashboardApp : ViewBase return new Fragment( mainLayout, + versionSheetOpen.Value && versionCompare.Count > 0 + ? new DashboardVersionPickerSheet( + versionSheetOpen, + dashboardFocusVersion, + versionCompare, + (page.IvyVersion ?? "").Trim()) + : new Empty(), runDialogOpen.Value && selectedRunId.Value.HasValue ? new TestRunResultsDialog(runDialogOpen, selectedRunId.Value.Value, editSheetOpen, editQuestionId) : new Empty(), @@ -242,6 +296,20 @@ public class DashboardApp : ViewBase : new Empty()); } + private const char DashboardQueryKeySep = '\u001f'; + + private static string DashboardQueryKey(string? ivyVersionFocus) => + "dashboard-stats-page" + DashboardQueryKeySep + (ivyVersionFocus ?? ""); + + private static string? ParseDashboardFocusFromQueryKey(string key) + { + var i = key.IndexOf(DashboardQueryKeySep); + if (i < 0 || i >= key.Length - 1) + return null; + var tail = key[(i + 1)..].Trim(); + return string.IsNullOrEmpty(tail) ? null : tail; + } + private static string CapitalizeEnv(string env) => env.Equals("staging", StringComparison.OrdinalIgnoreCase) ? "Staging" : "Production"; @@ -279,7 +347,7 @@ private static object FormatDeltaWithTrend(double delta, string unit, bool highe } private static async Task LoadDashboardPageAsync( - AppDbContextFactory factory, CancellationToken ct) + AppDbContextFactory factory, string? ivyVersionFocus, CancellationToken ct) { await using var ctx = factory.CreateDbContext(); @@ -297,11 +365,6 @@ private static object FormatDeltaWithTrend(double delta, string unit, bool highe if (runs.Count == 0) return null; - var latestProd = runs.FirstOrDefault(r => NormalizeEnvironment(r.Environment) == "production"); - var latestStag = runs.FirstOrDefault(r => NormalizeEnvironment(r.Environment) == "staging"); - var primaryRun = latestProd ?? latestStag ?? runs[0]; - var primaryEnv = NormalizeEnvironment(primaryRun.Environment); - var latestProdByVersion = new Dictionary(StringComparer.OrdinalIgnoreCase); var latestStagByVersion = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var r in runs) @@ -313,6 +376,38 @@ private static object FormatDeltaWithTrend(double delta, string unit, bool highe dict[v] = r; } + var latestProd = runs.FirstOrDefault(r => NormalizeEnvironment(r.Environment) == "production"); + var latestStag = runs.FirstOrDefault(r => NormalizeEnvironment(r.Environment) == "staging"); + + TestRunEntity primaryRun; + string primaryEnv; + var focus = (ivyVersionFocus ?? "").Trim(); + if (string.IsNullOrEmpty(focus)) + { + primaryRun = latestProd ?? latestStag ?? runs[0]; + primaryEnv = NormalizeEnvironment(primaryRun.Environment); + } + else + { + latestProdByVersion.TryGetValue(focus, out var prFocus); + latestStagByVersion.TryGetValue(focus, out var srFocus); + if (prFocus != null) + { + primaryRun = prFocus; + primaryEnv = "production"; + } + else if (srFocus != null) + { + primaryRun = srFocus; + primaryEnv = "staging"; + } + else + { + primaryRun = latestProd ?? latestStag ?? runs[0]; + primaryEnv = NormalizeEnvironment(primaryRun.Environment); + } + } + var allVersions = latestProdByVersion.Keys .Union(latestStagByVersion.Keys, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -417,12 +512,16 @@ static void FillMetrics( } else { - var prevRun = await ctx.TestRuns.AsNoTracking() + // Cannot call NormalizeEnvironment inside IQueryable — EF cannot translate it to SQL. + var prevRunBase = ctx.TestRuns.AsNoTracking() .Where(r => r.StartedAt < primaryRun.StartedAt && r.CompletedAt != null - && runIdsWithData.Contains(r.Id) - && NormalizeEnvironment(r.Environment) == primaryEnv) + && runIdsWithData.Contains(r.Id)); + var prevRunQuery = string.Equals(primaryEnv, "staging", StringComparison.Ordinal) + ? prevRunBase.Where(r => (r.Environment ?? "").Trim().ToLower() == "staging") + : prevRunBase.Where(r => (r.Environment ?? "").Trim().ToLower() != "staging"); + var prevRun = await prevRunQuery .OrderByDescending(r => r.StartedAt) .FirstOrDefaultAsync(ct); if (prevRun != null) @@ -562,7 +661,7 @@ private static DashboardData BuildDashboardData( } /// Semantic-ish ordering so 1.2.26 < 1.2.27 < 1.10.0. - private static int CompareVersionStrings(string? a, string? b) + internal static int CompareVersionStrings(string? a, string? b) { if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)) return 0; if (string.IsNullOrEmpty(a)) return -1; @@ -591,6 +690,78 @@ private static int CompareVersionStrings(string? a, string? b) } } +internal sealed class DashboardVersionPickerSheet( + IState isOpen, + IState dashboardFocusVersion, + IReadOnlyList rows, + string currentDisplayedVersion) : ViewBase +{ + private static readonly Comparer VersionComparerDescending = + Comparer.Create((a, b) => DashboardApp.CompareVersionStrings(a, b)); + + /// One headline success rate: average when both envs have data, otherwise the single non-zero side. + private static string FormatCombinedAnswerRate(VersionCompareRow row) + { + var p = row.ProductionAnswerRate; + var s = row.StagingAnswerRate; + if (p <= 0 && s <= 0) return "—"; + if (s <= 0) return $"{p:F1}%"; + if (p <= 0) return $"{s:F1}%"; + return $"{(p + s) / 2.0:F1}%"; + } + + public override object? Build() + { + var versionSearch = UseState(""); + + if (!isOpen.Value) + return null; + + var q = versionSearch.Value.Trim(); + var filteredRows = (string.IsNullOrEmpty(q) + ? rows + : rows.Where(r => r.Version.Contains(q, StringComparison.OrdinalIgnoreCase))) + .OrderByDescending(r => r.Version, VersionComparerDescending) + .ToList(); + + var listItems = new List(); + foreach (var row in filteredRows) + { + var v = row.Version; + var isCurrent = string.Equals(v, currentDisplayedVersion, StringComparison.OrdinalIgnoreCase); + var title = isCurrent ? $"{v} (current)" : v; + listItems.Add(new ListItem( + title: title, + subtitle: FormatCombinedAnswerRate(row), + onClick: () => + { + dashboardFocusVersion.Set(v); + isOpen.Set(false); + })); + } + + var sheetDescription = string.IsNullOrEmpty(q) + ? $"{rows.Count} version(s) with test data" + : $"{filteredRows.Count} matching · {rows.Count} total"; + + // Sheet body is a fixed column: intro + search stay put; only the list scrolls (flex child with Grow). + var body = Layout.Vertical().Height(Size.Full()) + | versionSearch.ToSearchInput().Placeholder("Search versions…").Width(Size.Full()) + | (!string.IsNullOrEmpty(q) && filteredRows.Count == 0 + ? (object)Text.Block("No versions match your search — try another query or clear the filter.").Muted() + : new Empty()) + | new List(listItems.ToArray()).Height(Size.Full()); + + return new Sheet( + _ => isOpen.Set(false), + body, + title: "Ivy version", + description: sheetDescription) + .Width(Size.Fraction(0.28f)) + .Height(Size.Full()); + } +} + internal record DashboardPageModel( string IvyVersion, DateTime RunStartedAt, From 96081e8fb7287a68e6b68f73d5af67ae1133048c Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 7 Apr 2026 00:36:43 +0300 Subject: [PATCH 36/39] refactor: Update DashboardApp, QuestionsApp, and related components to include editPreviewResultId state management, enhancing question editing functionality and improving data handling across dialogs --- .../ivy-ask-statistics/Apps/DashboardApp.cs | 14 +- .../Apps/QuestionEditSheet.cs | 156 ++++++++++++++++-- .../ivy-ask-statistics/Apps/QuestionsApp.cs | 14 +- .../Apps/TestRunResultsDialog.cs | 6 +- .../Apps/WidgetQuestionsDialog.cs | 4 +- 5 files changed, 173 insertions(+), 21 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs index 4f3f71fb..0f50c4e9 100644 --- a/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/DashboardApp.cs @@ -17,8 +17,9 @@ public class DashboardApp : ViewBase var navigation = Context.UseNavigation(); var selectedRunId = UseState(null); var runDialogOpen = UseState(false); - var editSheetOpen = UseState(false); - var editQuestionId = UseState(Guid.Empty); + var editSheetOpen = UseState(false); + var editQuestionId = UseState(Guid.Empty); + var editPreviewResultId = UseState(null); var envOverride = UseState("production"); var dashboardFocusVersion = UseState(null); var versionSheetOpen = UseState(false); @@ -289,10 +290,15 @@ public class DashboardApp : ViewBase (page.IvyVersion ?? "").Trim()) : new Empty(), runDialogOpen.Value && selectedRunId.Value.HasValue - ? new TestRunResultsDialog(runDialogOpen, selectedRunId.Value.Value, editSheetOpen, editQuestionId) + ? new TestRunResultsDialog( + runDialogOpen, + selectedRunId.Value.Value, + editSheetOpen, + editQuestionId, + editPreviewResultId) : new Empty(), editSheetOpen.Value - ? new QuestionEditSheet(editSheetOpen, editQuestionId.Value) + ? new QuestionEditSheet(editSheetOpen, editQuestionId.Value, editPreviewResultId) : new Empty()); } diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs index 7b5c495d..f5b1123d 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionEditSheet.cs @@ -1,6 +1,9 @@ namespace IvyAskStatistics.Apps; -internal sealed class QuestionEditSheet(IState isOpen, Guid questionId) : ViewBase +internal sealed class QuestionEditSheet( + IState isOpen, + Guid questionId, + IState previewResultId) : ViewBase { private record EditRequest { @@ -15,23 +18,90 @@ private record EditRequest public bool IsActive { get; init; } = true; } + /// + /// Question row plus optional response preview: either the specific row + /// (when opened from a run results table) or the latest answer across all runs (elsewhere). + /// + private sealed record QuestionEditPayload( + QuestionEntity Question, + string? AnswerText, + AnswerPreviewSource PreviewSource); + + private enum AnswerPreviewSource + { + /// This run's result row (may be empty). + ThisResultRow, + + /// Latest successful, else latest with text — any run. + GlobalHistory, + } + public override object? Build() { var factory = UseService(); var queryService = UseService(); + var isSaving = UseState(false); - var questionQuery = UseQuery( - key: questionId, - fetcher: async (id, ct) => + var questionQuery = UseQuery( + key: (questionId, previewResultId.Value), + fetcher: async (key, ct) => { + var (id, focusResult) = key; await using var ctx = factory.CreateDbContext(); - return await ctx.Questions.AsNoTracking().FirstOrDefaultAsync(q => q.Id == id, ct); + var q = await ctx.Questions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + if (q == null) + return null; + + if (focusResult is Guid fr && fr != Guid.Empty) + { + var text = await ctx.TestResults.AsNoTracking() + .Where(r => r.Id == fr && r.QuestionId == id) + .Select(r => r.ResponseText) + .FirstOrDefaultAsync(ct); + var trimmed = string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + return new QuestionEditPayload(q, trimmed, AnswerPreviewSource.ThisResultRow); + } + + var lastSuccess = await ctx.TestResults.AsNoTracking() + .Where(r => r.QuestionId == id && r.IsSuccess) + .OrderByDescending(r => r.CreatedAt) + .Select(r => r.ResponseText) + .FirstOrDefaultAsync(ct); + + string? answer = null; + if (!string.IsNullOrWhiteSpace(lastSuccess)) + answer = lastSuccess.Trim(); + else + { + var lastAny = await ctx.TestResults.AsNoTracking() + .Where(r => r.QuestionId == id) + .OrderByDescending(r => r.CreatedAt) + .Select(r => r.ResponseText) + .FirstOrDefaultAsync(ct); + if (!string.IsNullOrWhiteSpace(lastAny)) + answer = lastAny.Trim(); + } + + return new QuestionEditPayload(q, answer, AnswerPreviewSource.GlobalHistory); }); if (questionQuery.Loading || questionQuery.Value == null) - return Skeleton.Form().ToSheet(isOpen, "Edit Question"); + return new Sheet( + _ => + { + isOpen.Set(false); + previewResultId.Set(null); + }, + Skeleton.Form(), + title: "Edit Question", + description: "Loading question…") + .Width(Size.Fraction(1f / 3f)) + .Height(Size.Full()); - var q = questionQuery.Value; + var payload = questionQuery.Value; + var q = payload.Question; + var answer = payload.AnswerText; + var preview = payload.PreviewSource; var form = new EditRequest { @@ -43,12 +113,77 @@ private record EditRequest var difficulties = new[] { "easy", "medium", "hard" }.ToOptions(); - return form + var formBuilder = form .ToForm() .Builder(f => f.QuestionText, f => f.ToTextareaInput()) .Builder(f => f.Difficulty, f => f.ToSelectInput(difficulties)) - .OnSubmit(OnSubmit) - .ToSheet(isOpen, "Edit Question"); + .Builder(f => f.Category, f => f.ToTextInput()) + .Builder(f => f.IsActive, f => f.ToSwitchInput()) + .OnSubmit(OnSubmit); + + var (onSubmit, formView, validationView, loading) = formBuilder.UseForm(Context); + + object answerPreview = preview switch + { + AnswerPreviewSource.ThisResultRow when string.IsNullOrWhiteSpace(answer) + => new Callout( + "No response text for this row in this test run (e.g. 404 / no answer).", + variant: CalloutVariant.Info), + AnswerPreviewSource.ThisResultRow + => new Card( + Layout.Vertical().Gap(2) + | Text.Block("Response for the result row you opened (read-only).").Muted() + | Text.Markdown(answer!)) + .Title("This run") + .Icon(Icons.FileText), + AnswerPreviewSource.GlobalHistory when string.IsNullOrWhiteSpace(answer) + => new Empty(), + AnswerPreviewSource.GlobalHistory + => new Card( + Layout.Vertical().Gap(2) + | Text.Block("Latest recorded response across all test runs (read-only).").Muted() + | Text.Markdown(answer!)) + .Title("Answer") + .Icon(Icons.FileText), + _ => new Empty() + }; + + var scrollBody = Layout.Vertical().Gap(4) + | formView + | answerPreview; + + var footer = Layout.Horizontal().Gap(2) + | new Button("Save") + .Variant(ButtonVariant.Primary) + .Loading(loading || isSaving.Value) + .Disabled(loading || isSaving.Value) + .OnClick(async _ => + { + isSaving.Set(true); + try + { + if (await onSubmit()) + isOpen.Set(false); + } + finally + { + isSaving.Set(false); + } + }) + | validationView; + + var sheetBody = new FooterLayout(footer, scrollBody); + + return new Sheet( + _ => + { + isOpen.Set(false); + previewResultId.Set(null); + }, + sheetBody, + title: "Edit Question") + .Width(Size.Fraction(1f / 3f)) + .Height(Size.Full()); async Task OnSubmit(EditRequest? request) { @@ -64,7 +199,6 @@ async Task OnSubmit(EditRequest? request) queryService.RevalidateByTag(("widget-questions", entity.Widget)); queryService.RevalidateByTag("widget-summary"); queryService.RevalidateByTag(RunApp.TestQuestionsQueryTag); - isOpen.Set(false); } } } diff --git a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs index a4f946cf..72404554 100644 --- a/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/QuestionsApp.cs @@ -32,8 +32,9 @@ public class QuestionsApp : ViewBase var deleteRequest = UseState(null); var viewDialogOpen = UseState(false); var viewDialogWidget = UseState(""); - var editSheetOpen = UseState(false); - var editQuestionId = UseState(Guid.Empty); + var editSheetOpen = UseState(false); + var editQuestionId = UseState(Guid.Empty); + var editPreviewResultId = UseState(null); var refreshToken = UseRefreshToken(); var genProgress = UseState(null); var (alertView, showAlert) = UseAlert(); @@ -492,11 +493,16 @@ static string IdleStatus(WidgetRow r) }); object? questionsDialog = viewDialogOpen.Value && !string.IsNullOrEmpty(viewDialogWidget.Value) - ? new WidgetQuestionsDialog(viewDialogOpen, viewDialogWidget.Value, editSheetOpen, editQuestionId) + ? new WidgetQuestionsDialog( + viewDialogOpen, + viewDialogWidget.Value, + editSheetOpen, + editQuestionId, + editPreviewResultId) : null; object? editSheet = editSheetOpen.Value && editQuestionId.Value != Guid.Empty - ? new QuestionEditSheet(editSheetOpen, editQuestionId.Value) + ? new QuestionEditSheet(editSheetOpen, editQuestionId.Value, editPreviewResultId) : null; return Layout.Vertical().Height(Size.Full()) diff --git a/project-demos/ivy-ask-statistics/Apps/TestRunResultsDialog.cs b/project-demos/ivy-ask-statistics/Apps/TestRunResultsDialog.cs index 78988455..6c21164d 100644 --- a/project-demos/ivy-ask-statistics/Apps/TestRunResultsDialog.cs +++ b/project-demos/ivy-ask-statistics/Apps/TestRunResultsDialog.cs @@ -5,7 +5,8 @@ internal sealed class TestRunResultsDialog( IState isOpen, Guid runId, IState editSheetOpen, - IState editQuestionId) : ViewBase + IState editQuestionId, + IState editPreviewResultId) : ViewBase { public override object? Build() { @@ -91,8 +92,10 @@ async Task DeleteAsync(Guid resultId) .Width(r => r.Difficulty, Size.Px(80)) .Width(r => r.Status, Size.Px(90)) .Width(r => r.ResponseTimeMs, Size.Px(80)) + .Width(r => r.Response, Size.Px(300)) .Hidden(r => r.ResultId) .Hidden(r => r.QuestionId) + .AlignContent(r => r.ResponseTimeMs, Align.Left) .RowActions( MenuItem.Default(Icons.Pencil, "edit").Label("Edit question").Tag("edit"), MenuItem.Default(Icons.Trash2, "delete").Label("Delete result").Tag("delete")) @@ -109,6 +112,7 @@ async Task DeleteAsync(Guid resultId) if (tag == "edit") { editQuestionId.Set(row.QuestionId); + editPreviewResultId.Set(row.ResultId); editSheetOpen.Set(true); } else if (tag == "delete") diff --git a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs index a75b5cc2..9ee1146b 100644 --- a/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs +++ b/project-demos/ivy-ask-statistics/Apps/WidgetQuestionsDialog.cs @@ -5,7 +5,8 @@ internal sealed class WidgetQuestionsDialog( IState isOpen, string widgetName, IState editSheetOpen, - IState editQuestionId) : ViewBase + IState editQuestionId, + IState editPreviewResultId) : ViewBase { public override object? Build() { @@ -92,6 +93,7 @@ async Task DeleteAsync(Guid id) if (tag == "edit") { + editPreviewResultId.Set(null); editQuestionId.Set(id); editSheetOpen.Set(true); } From 33edcb96332b28ae9b70ba8fbf08e34d5e831d15 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 7 Apr 2026 00:46:12 +0300 Subject: [PATCH 37/39] refactor: Simplify RunApp and TabLoadingSkeletons by replacing hardcoded skeleton layouts with dynamic KPI card and chart components, improving code maintainability and enhancing loading UI consistency --- .../ivy-ask-statistics/Apps/RunApp.cs | 6 +- .../Apps/TabLoadingSkeletons.cs | 123 ++++++++++++------ 2 files changed, 84 insertions(+), 45 deletions(-) diff --git a/project-demos/ivy-ask-statistics/Apps/RunApp.cs b/project-demos/ivy-ask-statistics/Apps/RunApp.cs index 20a69837..7c87ce09 100644 --- a/project-demos/ivy-ask-statistics/Apps/RunApp.cs +++ b/project-demos/ivy-ask-statistics/Apps/RunApp.cs @@ -523,11 +523,7 @@ private static object BuildLastSavedRunPanel( { return Layout.Vertical().Gap(2) | Text.Block("Loading last saved results…").Muted() - | Layout.Grid().Columns(4).Height(Size.Fit()) - | new Skeleton().Height(Size.Units(14)) - | new Skeleton().Height(Size.Units(14)) - | new Skeleton().Height(Size.Units(14)) - | new Skeleton().Height(Size.Units(14)); + | TabLoadingSkeletons.RunMetricsRow(); } var s = effective; diff --git a/project-demos/ivy-ask-statistics/Apps/TabLoadingSkeletons.cs b/project-demos/ivy-ask-statistics/Apps/TabLoadingSkeletons.cs index 69d1b67b..8e6d7185 100644 --- a/project-demos/ivy-ask-statistics/Apps/TabLoadingSkeletons.cs +++ b/project-demos/ivy-ask-statistics/Apps/TabLoadingSkeletons.cs @@ -6,52 +6,95 @@ internal static class TabLoadingSkeletons { public static object Dashboard() { - var kpi = Layout.Grid().Columns(5).Height(Size.Fit()) - | new Skeleton().Height(Size.Units(12)) - | new Skeleton().Height(Size.Units(12)) - | new Skeleton().Height(Size.Units(12)) - | new Skeleton().Height(Size.Units(12)) - | new Skeleton().Height(Size.Units(12)); - - var charts1 = Layout.Grid().Columns(3).Height(Size.Fit()) - | new Skeleton().Height(Size.Units(70)) - | new Skeleton().Height(Size.Units(70)) - | new Skeleton().Height(Size.Units(70)); - - var charts2 = Layout.Grid().Columns(3).Height(Size.Fit()) - | new Skeleton().Height(Size.Units(70)) - | new Skeleton().Height(Size.Units(70)) - | new Skeleton().Height(Size.Units(70)); - - return Layout.Vertical().Height(Size.Fit()) - | new Skeleton().Height(Size.Units(10)).Width(Size.Px(160)) - | new Skeleton().Height(Size.Units(7)).Width(Size.Px(220)) - | kpi - | charts1 - | charts2 - | new Skeleton().Height(Size.Units(36)).Width(Size.Fraction(1f)); - } + // Mirrors : 5 KPI cards → 3 version-compare charts → 4 env charts → Test runs table. + static object KpiCard(string title, Icons icon) => + new Card( + Layout.Vertical().AlignContent(Align.Center).Gap(1) + | (Layout.Horizontal().AlignContent(Align.Center).Gap(1) + | new Skeleton().Height(Size.Units(9)).Width(Size.Px(56)) + | new Skeleton().Height(Size.Units(5)).Width(Size.Px(40))) + ).Title(title).Icon(icon); - public static object RunTab() - { - return Layout.Vertical().Height(Size.Fit()) - | Layout.Horizontal().Height(Size.Fit()) - | new Skeleton().Height(Size.Units(14)).Width(Size.Px(200)) - | new Skeleton().Height(Size.Units(14)).Width(Size.Px(120)) - | new Skeleton().Height(Size.Units(14)).Width(Size.Px(100)) - | new Skeleton().Height(Size.Units(280)).Width(Size.Fraction(1f)); + var kpiRow = Layout.Grid().Columns(5).Height(Size.Fit()) + | KpiCard("Answer success", Icons.CircleCheck) + | KpiCard("Avg latency", Icons.Timer) + | KpiCard("No answer + errors", Icons.CircleX) + | KpiCard("Weakest widget", Icons.Ban) + | KpiCard("Ivy version", Icons.Tag); + + static object VersionChartCard(string title) => + new Card( + Layout.Vertical().Gap(3) + | new Skeleton().Height(Size.Units(5)).Width(Size.Px(180)) + | new Skeleton().Height(Size.Units(48)).Width(Size.Fraction(1f))) + .Title(title) + .Height(Size.Units(70)); + + var versionChartsRow = Layout.Grid().Columns(3).Height(Size.Fit()) + | VersionChartCard("Success rate · production vs staging") + | VersionChartCard("Avg response · production vs staging") + | VersionChartCard("Outcomes · production vs staging"); + + static object DetailChartCard(string title) => + new Card( + Layout.Vertical().Gap(3) + | new Skeleton().Height(Size.Units(5)).Width(Size.Px(200)) + | new Skeleton().Height(Size.Units(48)).Width(Size.Fraction(1f))) + .Title(title) + .Height(Size.Units(70)); + + const string envLabel = "Production"; + var detailChartsRow = Layout.Grid().Columns(4).Height(Size.Fit()) + | DetailChartCard($"Worst widgets — rate % ({envLabel})") + | DetailChartCard($"Latency by widget — avg / max ({envLabel})") + | DetailChartCard($"Results by difficulty ({envLabel})") + | DetailChartCard($"Result mix ({envLabel})"); + + var testRunsCard = new Card( + Layout.Vertical().Gap(2) + | new Skeleton().Height(Size.Units(5)).Width(Size.Fraction(1f)) + | new Skeleton().Height(Size.Units(5)).Width(Size.Fraction(1f)) + | new Skeleton().Height(Size.Units(5)).Width(Size.Fraction(1f)) + | new Skeleton().Height(Size.Units(115)).Width(Size.Fraction(1f))) + .Title("Test runs"); + + return Layout.Vertical().Height(Size.Full()) + | kpiRow + | versionChartsRow + | detailChartsRow + | (Layout.Vertical() | testRunsCard); } - public static object QuestionsTab() + /// Four KPI cards while loads the last saved run (titles match BuildOutcomeMetricCards). + public static object RunMetricsRow() { - return Layout.Vertical().Height(Size.Fit()) - | Layout.Horizontal().Height(Size.Fit()) - | new Skeleton().Height(Size.Units(12)).Width(Size.Px(140)) - | new Skeleton().Height(Size.Units(12)).Width(Size.Px(140)) - | new Skeleton().Height(Size.Units(12)).Width(Size.Px(140)) - | new Skeleton().Height(Size.Units(280)).Width(Size.Fraction(1f)); + static object KpiCard(string title, Icons icon) => + new Card( + Layout.Vertical().AlignContent(Align.Center).Gap(2) + | new Skeleton().Height(Size.Units(9)).Width(Size.Px(72)) + | new Skeleton().Height(Size.Units(4)).Width(Size.Px(140))) + .Title(title) + .Icon(icon); + + return Layout.Grid().Columns(4).Height(Size.Fit()) + | KpiCard("Answer rate", Icons.CircleCheck) + | KpiCard("No answer", Icons.Ban) + | KpiCard("Errors", Icons.CircleX) + | KpiCard("Avg response", Icons.Timer); } + public static object RunTab() => + DataTableToolbarAndBody(tableBodyHeightUnits: 280); + + public static object QuestionsTab() => + DataTableToolbarAndBody(tableBodyHeightUnits: 260); + + /// Filter-sized square + single block for the DataTable body (, ). + private static object DataTableToolbarAndBody(int tableBodyHeightUnits) => + Layout.Vertical().Height(Size.Full()).Gap(3) + | new Skeleton().Height(Size.Px(36)).Width(Size.Px(36)) + | new Skeleton().Height(Size.Units(tableBodyHeightUnits)).Width(Size.Fraction(1f)); + public static object DialogTable() { return Layout.Vertical().Width(Size.Fraction(1f)).Height(Size.Fit()) From 050a0545544b9a67c3fd4f256c20738ab9a06e4b Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 7 Apr 2026 00:48:13 +0300 Subject: [PATCH 38/39] feat: Add BasicAuthConnection class to implement basic authentication, including methods for service registration, secret management, and connection testing, enhancing authentication capabilities in the IvyAskStatistics project --- .../Connections/Auth/BasicAuthConnection.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 project-demos/ivy-ask-statistics/Connections/Auth/BasicAuthConnection.cs diff --git a/project-demos/ivy-ask-statistics/Connections/Auth/BasicAuthConnection.cs b/project-demos/ivy-ask-statistics/Connections/Auth/BasicAuthConnection.cs new file mode 100644 index 00000000..ec95e06c --- /dev/null +++ b/project-demos/ivy-ask-statistics/Connections/Auth/BasicAuthConnection.cs @@ -0,0 +1,37 @@ +using Ivy; +using Microsoft.Extensions.Configuration; + +namespace IvyAskStatistics.Connections.Auth; + +public class BasicAuthConnection : IConnection, IHaveSecrets +{ + public string GetContext(string connectionPath) => string.Empty; + + public string GetName() => "BasicAuth"; + + public string GetNamespace() => typeof(BasicAuthConnection).Namespace ?? ""; + + public string GetConnectionType() => "Auth"; + + public ConnectionEntity[] GetEntities() => []; + + public void RegisterServices(Server server) + { + server.UseAuth(); + } + + public Secret[] GetSecrets() => + [ + new("BasicAuth:Users"), + new("BasicAuth:HashSecret"), + new("BasicAuth:JwtSecret"), + new("BasicAuth:JwtIssuer"), + new("BasicAuth:JwtAudience") + ]; + + public async Task<(bool ok, string? message)> TestConnection(IConfiguration config) + { + await Task.CompletedTask; + return (true, "Basic Auth configured"); + } +} From 035921540e6a2ea323107427fa10e574c7aa2ebc Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Tue, 7 Apr 2026 00:53:35 +0300 Subject: [PATCH 39/39] feat: Add Dockerfile and .dockerignore for IvyAskStatistics project, enabling containerization and excluding unnecessary files from the Docker build context --- .../ivy-ask-statistics/.dockerignore | 25 ++++++++++++++ project-demos/ivy-ask-statistics/Dockerfile | 34 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 project-demos/ivy-ask-statistics/.dockerignore create mode 100644 project-demos/ivy-ask-statistics/Dockerfile diff --git a/project-demos/ivy-ask-statistics/.dockerignore b/project-demos/ivy-ask-statistics/.dockerignore new file mode 100644 index 00000000..2f32bfe4 --- /dev/null +++ b/project-demos/ivy-ask-statistics/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/project-demos/ivy-ask-statistics/Dockerfile b/project-demos/ivy-ask-statistics/Dockerfile new file mode 100644 index 00000000..2ad4e0fe --- /dev/null +++ b/project-demos/ivy-ask-statistics/Dockerfile @@ -0,0 +1,34 @@ +# Base runtime image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 80 + +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +# Copy and restore +COPY ["IvyAskStatistics.csproj", "./"] +RUN dotnet restore "IvyAskStatistics.csproj" + +# Copy everything and build +COPY . . +RUN dotnet build "IvyAskStatistics.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# Publish stage +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "IvyAskStatistics.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=true + +# Final runtime image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# Set environment variables +ENV PORT=80 +ENV ASPNETCORE_URLS="http://+:80" + +# Run the executable +ENTRYPOINT ["dotnet", "./IvyAskStatistics.dll"]