diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.Helpers.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.Helpers.cs new file mode 100644 index 00000000..301376a0 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.Helpers.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; + +namespace SimpleModule.Cli.Commands.Dev; + +public sealed partial class DevCommand +{ + private static int GetSafePid(Process process) + { + try + { + return process.Id; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + return -1; + } +#pragma warning restore CA1031 + } + + /// + /// Parse launchSettings.json to find the ports ASP.NET will bind to. + /// Falls back to the default ports (5001, 5000) if the file can't be read. + /// + private static List DiscoverDotnetPorts(string hostProjectPath) + { + var ports = new List(); + var hostDir = Path.GetDirectoryName(hostProjectPath); + if (hostDir is null) + { + return [5001, 5000]; + } + + var launchSettingsPath = Path.Combine(hostDir, "Properties", "launchSettings.json"); + + if (!File.Exists(launchSettingsPath)) + { + return [5001, 5000]; + } + + try + { + var json = File.ReadAllText(launchSettingsPath); + + // Extract applicationUrl values and parse ports from them. + // Format: "applicationUrl": "https://localhost:5001;http://localhost:5000" + // Use simple string parsing to avoid adding a JSON dependency to the CLI. + var searchKey = "\"applicationUrl\""; + var idx = json.IndexOf(searchKey, StringComparison.OrdinalIgnoreCase); + while (idx >= 0) + { + var colonIdx = json.IndexOf(':', idx + searchKey.Length); + if (colonIdx < 0) + { + break; + } + + var quoteStart = json.IndexOf('"', colonIdx + 1); + if (quoteStart < 0) + { + break; + } + + var quoteEnd = json.IndexOf('"', quoteStart + 1); + if (quoteEnd < 0) + { + break; + } + + var urlValue = json[(quoteStart + 1)..quoteEnd]; + foreach (var url in urlValue.Split(';')) + { + // Extract port from URL like "https://localhost:5001" + var lastColon = url.LastIndexOf(':'); + if ( + lastColon >= 0 + && int.TryParse( + url[(lastColon + 1)..], + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out var port + ) + ) + { + if (!ports.Contains(port)) + { + ports.Add(port); + } + } + } + + idx = json.IndexOf(searchKey, quoteEnd + 1, StringComparison.OrdinalIgnoreCase); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // If we can't parse, fall back to defaults + } +#pragma warning restore CA1031 + + return ports.Count > 0 ? ports : [5001, 5000]; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.Shutdown.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.Shutdown.cs new file mode 100644 index 00000000..859c1bc1 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.Shutdown.cs @@ -0,0 +1,221 @@ +using Spectre.Console; + +namespace SimpleModule.Cli.Commands.Dev; + +public sealed partial class DevCommand +{ + /// + /// Graceful shutdown: send termination signals to children, wait for them to exit, + /// then force-kill any stragglers. + /// + private void GracefulShutdown() + { + // Transition: running → graceful + if ( + Interlocked.CompareExchange( + ref _shutdownState, + ShutdownPhase.Graceful, + ShutdownPhase.Running + ) != ShutdownPhase.Running + ) + { + return; + } + + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[cyan]Stopping all processes gracefully...[/]"); + + // Phase 1: Send graceful termination signal to the entire process tree + foreach (var (process, label) in _processes) + { + SendTermSignal(process, label); + } + + // Phase 2: Wait for graceful exit + if (WaitAllExit(GracefulShutdownTimeoutMs)) + { + AnsiConsole.MarkupLine("[green]All processes stopped.[/]"); + return; + } + + // Phase 3: Force-kill survivors + AnsiConsole.MarkupLine( + "[yellow]Some processes did not exit gracefully. Force-killing...[/]" + ); + ForceKillAll(); + + if (!WaitAllExit(ForceKillTimeoutMs)) + { + AnsiConsole.MarkupLine( + "[red]Warning: Some processes may still be running. Check manually.[/]" + ); + LogSurvivorPids(); + } + else + { + AnsiConsole.MarkupLine("[green]All processes stopped.[/]"); + } + } + + /// + /// Force-kill all processes and their entire process trees. + /// Uses .NET's cross-platform Kill(entireProcessTree: true) which + /// walks /proc on Linux, libproc on macOS, and NtQuerySystemInformation on Windows. + /// + private void ForceKillAll() + { + // Transition to force state (from any state) + Interlocked.Exchange(ref _shutdownState, ShutdownPhase.Force); + + foreach (var (process, label) in _processes) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + // Kill can fail if process exited between check and kill, + // or if we lack permissions for a child process. + // Fall back to killing just the direct process. + AnsiConsole.MarkupLine( + $"[dim][[{label}]][/] [dim]Tree kill failed (PID {GetSafePid(process)}): {ex.Message}[/]" + ); + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: false); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Truly unreachable + } +#pragma warning restore CA1031 + } + } + } + + /// + /// Wait for all tracked processes to exit within the given timeout. + /// Returns true if all exited, false if any are still alive. + /// + private bool WaitAllExit(int timeoutMs) + { + var deadline = Environment.TickCount64 + timeoutMs; + + foreach (var (process, _) in _processes) + { + var remaining = (int)(deadline - Environment.TickCount64); + if (remaining <= 0) + { + return AllExited(); + } + + try + { + process.WaitForExit(remaining); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Process may already be disposed + } +#pragma warning restore CA1031 + } + + return AllExited(); + } + + private bool AllExited() + { + foreach (var (process, _) in _processes) + { + try + { + if (!process.HasExited) + { + return false; + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Process disposed — treat as exited + } +#pragma warning restore CA1031 + } + + return true; + } + + private void LogSurvivorPids() + { + foreach (var (process, label) in _processes) + { + try + { + if (!process.HasExited) + { + AnsiConsole.MarkupLine($"[red][[{label}]][/] Still running: PID {process.Id}"); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Ignore + } +#pragma warning restore CA1031 + } + } + + private void DisposeAll() + { + foreach (var (process, _) in _processes) + { + try + { + process.Dispose(); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Best-effort dispose + } +#pragma warning restore CA1031 + } + + _processes.Clear(); + } + + private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) + { + if (_shutdownState == ShutdownPhase.Running) + { + // First Ctrl+C: graceful shutdown, cancel the default termination + e.Cancel = true; + GracefulShutdown(); + } + else + { + // Second Ctrl+C: force-kill immediately, let process terminate + AnsiConsole.MarkupLine("[red]Force-killing all processes...[/]"); + ForceKillAll(); + e.Cancel = false; + } + } + + private void OnProcessExit(object? sender, EventArgs e) + { + // Process is exiting (terminal closed, kill signal, etc.) + // Force-kill children to prevent orphans + ForceKillAll(); + } +} diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.Signals.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.Signals.cs new file mode 100644 index 00000000..20c5dd25 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.Signals.cs @@ -0,0 +1,242 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Spectre.Console; + +namespace SimpleModule.Cli.Commands.Dev; + +public sealed partial class DevCommand +{ + /// + /// Send a graceful termination signal to a process and all its descendants. + /// + /// Linux/macOS: Enumerates the process tree via the system process list + /// and sends SIGTERM to each descendant (leaf-first) then to the root. + /// Cannot use kill -TERM -pgid because children started with + /// UseShellExecute=false inherit the parent's process group. + /// + /// + /// Windows: Uses taskkill /PID <pid> /T which sends + /// WM_CLOSE to the entire process tree. + /// + /// + private static void SendTermSignal(Process process, string label) + { + try + { + if (process.HasExited) + { + return; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // taskkill /T walks the process tree and sends WM_CLOSE (graceful) + // /F is intentionally omitted — we want graceful first + using var taskkill = Process.Start( + new ProcessStartInfo + { + FileName = "taskkill", + Arguments = $"/PID {process.Id} /T", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + ); + taskkill?.WaitForExit(3000); + } + else + { + // Collect all descendant PIDs first, then signal leaf-first so + // parent processes don't respawn children before we reach them. + var descendants = GetDescendantPids(process.Id); + descendants.Reverse(); // leaf-first order + + foreach (var pid in descendants) + { + SendSigterm(pid); + } + + // Finally signal the root process itself + SendSigterm(process.Id); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + AnsiConsole.MarkupLine( + $"[dim][[{label}]][/] [dim]Failed to send term signal: {ex.Message}[/]" + ); + } + } + + /// + /// Send SIGTERM to a single PID via the kill command. + /// Silently ignores errors (process may have already exited). + /// + private static void SendSigterm(int pid) + { + try + { + using var kill = Process.Start( + new ProcessStartInfo + { + FileName = "kill", + Arguments = $"-TERM {pid}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + ); + kill?.WaitForExit(1000); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Process may have already exited + } +#pragma warning restore CA1031 + } + + /// + /// Get all descendant PIDs of a process by walking the process tree. + /// Works on both Linux (/proc) and macOS (pgrep -P). + /// Returns PIDs in breadth-first order (parents before children). + /// + private static List GetDescendantPids(int rootPid) + { + var descendants = new List(); + var queue = new Queue(); + queue.Enqueue(rootPid); + + while (queue.Count > 0) + { + var parentPid = queue.Dequeue(); + List children; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + children = GetChildPidsFromProc(parentPid); + } + else + { + // macOS (and other Unix): use pgrep -P + children = GetChildPidsViaPgrep(parentPid); + } + + foreach (var childPid in children) + { + descendants.Add(childPid); + queue.Enqueue(childPid); + } + } + + return descendants; + } + + /// + /// Linux: read /proc to find child PIDs. Each /proc/[pid]/stat has ppid as field 4. + /// + private static List GetChildPidsFromProc(int parentPid) + { + var children = new List(); + + try + { + foreach (var dir in Directory.GetDirectories("/proc")) + { + var dirName = Path.GetFileName(dir); + if (!int.TryParse(dirName, out var pid)) + { + continue; + } + + try + { + var stat = File.ReadAllText(Path.Combine(dir, "stat")); + // Format: pid (comm) state ppid ... + // Find the closing ')' to skip the command name (which can contain spaces) + var closeParen = stat.LastIndexOf(')'); + if (closeParen < 0) + { + continue; + } + + var fields = stat[(closeParen + 2)..].Split(' '); + // fields[0] = state, fields[1] = ppid + if ( + fields.Length > 1 + && int.TryParse(fields[1], out var ppid) + && ppid == parentPid + ) + { + children.Add(pid); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Process may have exited between directory listing and read + } +#pragma warning restore CA1031 + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // /proc not available or permission denied + } +#pragma warning restore CA1031 + + return children; + } + + /// + /// macOS/Unix: use pgrep -P <pid> to find child PIDs. + /// + private static List GetChildPidsViaPgrep(int parentPid) + { + var children = new List(); + + try + { + using var pgrep = Process.Start( + new ProcessStartInfo + { + FileName = "pgrep", + Arguments = $"-P {parentPid}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + ); + + if (pgrep is null) + { + return children; + } + + var output = pgrep.StandardOutput.ReadToEnd(); + pgrep.WaitForExit(2000); + + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(line.Trim(), out var pid)) + { + children.Add(pid); + } + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // pgrep not available + } +#pragma warning restore CA1031 + + return children; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs index cfe3cfbe..21f308c4 100644 --- a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs @@ -6,7 +6,7 @@ namespace SimpleModule.Cli.Commands.Dev; -public sealed class DevCommand : Command +public sealed partial class DevCommand : Command { /// How long to wait for graceful shutdown before force-killing. private const int GracefulShutdownTimeoutMs = 5000; @@ -227,551 +227,4 @@ private void WaitForExit() Thread.Sleep(300); } } - - /// - /// Graceful shutdown: send termination signals to children, wait for them to exit, - /// then force-kill any stragglers. - /// - private void GracefulShutdown() - { - // Transition: running → graceful - if ( - Interlocked.CompareExchange( - ref _shutdownState, - ShutdownPhase.Graceful, - ShutdownPhase.Running - ) != ShutdownPhase.Running - ) - { - return; - } - - AnsiConsole.MarkupLine(""); - AnsiConsole.MarkupLine("[cyan]Stopping all processes gracefully...[/]"); - - // Phase 1: Send graceful termination signal to the entire process tree - foreach (var (process, label) in _processes) - { - SendTermSignal(process, label); - } - - // Phase 2: Wait for graceful exit - if (WaitAllExit(GracefulShutdownTimeoutMs)) - { - AnsiConsole.MarkupLine("[green]All processes stopped.[/]"); - return; - } - - // Phase 3: Force-kill survivors - AnsiConsole.MarkupLine( - "[yellow]Some processes did not exit gracefully. Force-killing...[/]" - ); - ForceKillAll(); - - if (!WaitAllExit(ForceKillTimeoutMs)) - { - AnsiConsole.MarkupLine( - "[red]Warning: Some processes may still be running. Check manually.[/]" - ); - LogSurvivorPids(); - } - else - { - AnsiConsole.MarkupLine("[green]All processes stopped.[/]"); - } - } - - /// - /// Send a graceful termination signal to a process and all its descendants. - /// - /// Linux/macOS: Enumerates the process tree via the system process list - /// and sends SIGTERM to each descendant (leaf-first) then to the root. - /// Cannot use kill -TERM -pgid because children started with - /// UseShellExecute=false inherit the parent's process group. - /// - /// - /// Windows: Uses taskkill /PID <pid> /T which sends - /// WM_CLOSE to the entire process tree. - /// - /// - private static void SendTermSignal(Process process, string label) - { - try - { - if (process.HasExited) - { - return; - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // taskkill /T walks the process tree and sends WM_CLOSE (graceful) - // /F is intentionally omitted — we want graceful first - using var taskkill = Process.Start( - new ProcessStartInfo - { - FileName = "taskkill", - Arguments = $"/PID {process.Id} /T", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - } - ); - taskkill?.WaitForExit(3000); - } - else - { - // Collect all descendant PIDs first, then signal leaf-first so - // parent processes don't respawn children before we reach them. - var descendants = GetDescendantPids(process.Id); - descendants.Reverse(); // leaf-first order - - foreach (var pid in descendants) - { - SendSigterm(pid); - } - - // Finally signal the root process itself - SendSigterm(process.Id); - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch (Exception ex) -#pragma warning restore CA1031 - { - AnsiConsole.MarkupLine( - $"[dim][[{label}]][/] [dim]Failed to send term signal: {ex.Message}[/]" - ); - } - } - - /// - /// Send SIGTERM to a single PID via the kill command. - /// Silently ignores errors (process may have already exited). - /// - private static void SendSigterm(int pid) - { - try - { - using var kill = Process.Start( - new ProcessStartInfo - { - FileName = "kill", - Arguments = $"-TERM {pid}", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - } - ); - kill?.WaitForExit(1000); - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // Process may have already exited - } -#pragma warning restore CA1031 - } - - /// - /// Get all descendant PIDs of a process by walking the process tree. - /// Works on both Linux (/proc) and macOS (pgrep -P). - /// Returns PIDs in breadth-first order (parents before children). - /// - private static List GetDescendantPids(int rootPid) - { - var descendants = new List(); - var queue = new Queue(); - queue.Enqueue(rootPid); - - while (queue.Count > 0) - { - var parentPid = queue.Dequeue(); - List children; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - children = GetChildPidsFromProc(parentPid); - } - else - { - // macOS (and other Unix): use pgrep -P - children = GetChildPidsViaPgrep(parentPid); - } - - foreach (var childPid in children) - { - descendants.Add(childPid); - queue.Enqueue(childPid); - } - } - - return descendants; - } - - /// - /// Linux: read /proc to find child PIDs. Each /proc/[pid]/stat has ppid as field 4. - /// - private static List GetChildPidsFromProc(int parentPid) - { - var children = new List(); - - try - { - foreach (var dir in Directory.GetDirectories("/proc")) - { - var dirName = Path.GetFileName(dir); - if (!int.TryParse(dirName, out var pid)) - { - continue; - } - - try - { - var stat = File.ReadAllText(Path.Combine(dir, "stat")); - // Format: pid (comm) state ppid ... - // Find the closing ')' to skip the command name (which can contain spaces) - var closeParen = stat.LastIndexOf(')'); - if (closeParen < 0) - { - continue; - } - - var fields = stat[(closeParen + 2)..].Split(' '); - // fields[0] = state, fields[1] = ppid - if ( - fields.Length > 1 - && int.TryParse(fields[1], out var ppid) - && ppid == parentPid - ) - { - children.Add(pid); - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // Process may have exited between directory listing and read - } -#pragma warning restore CA1031 - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // /proc not available or permission denied - } -#pragma warning restore CA1031 - - return children; - } - - /// - /// macOS/Unix: use pgrep -P <pid> to find child PIDs. - /// - private static List GetChildPidsViaPgrep(int parentPid) - { - var children = new List(); - - try - { - using var pgrep = Process.Start( - new ProcessStartInfo - { - FileName = "pgrep", - Arguments = $"-P {parentPid}", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - } - ); - - if (pgrep is null) - { - return children; - } - - var output = pgrep.StandardOutput.ReadToEnd(); - pgrep.WaitForExit(2000); - - foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - if (int.TryParse(line.Trim(), out var pid)) - { - children.Add(pid); - } - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // pgrep not available - } -#pragma warning restore CA1031 - - return children; - } - - /// - /// Force-kill all processes and their entire process trees. - /// Uses .NET's cross-platform Kill(entireProcessTree: true) which - /// walks /proc on Linux, libproc on macOS, and NtQuerySystemInformation on Windows. - /// - private void ForceKillAll() - { - // Transition to force state (from any state) - Interlocked.Exchange(ref _shutdownState, ShutdownPhase.Force); - - foreach (var (process, label) in _processes) - { - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch (Exception ex) -#pragma warning restore CA1031 - { - // Kill can fail if process exited between check and kill, - // or if we lack permissions for a child process. - // Fall back to killing just the direct process. - AnsiConsole.MarkupLine( - $"[dim][[{label}]][/] [dim]Tree kill failed (PID {GetSafePid(process)}): {ex.Message}[/]" - ); - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: false); - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // Truly unreachable - } -#pragma warning restore CA1031 - } - } - } - - /// - /// Wait for all tracked processes to exit within the given timeout. - /// Returns true if all exited, false if any are still alive. - /// - private bool WaitAllExit(int timeoutMs) - { - var deadline = Environment.TickCount64 + timeoutMs; - - foreach (var (process, _) in _processes) - { - var remaining = (int)(deadline - Environment.TickCount64); - if (remaining <= 0) - { - return AllExited(); - } - - try - { - process.WaitForExit(remaining); - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // Process may already be disposed - } -#pragma warning restore CA1031 - } - - return AllExited(); - } - - private bool AllExited() - { - foreach (var (process, _) in _processes) - { - try - { - if (!process.HasExited) - { - return false; - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // Process disposed — treat as exited - } -#pragma warning restore CA1031 - } - - return true; - } - - private void LogSurvivorPids() - { - foreach (var (process, label) in _processes) - { - try - { - if (!process.HasExited) - { - AnsiConsole.MarkupLine($"[red][[{label}]][/] Still running: PID {process.Id}"); - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // Ignore - } -#pragma warning restore CA1031 - } - } - - private void DisposeAll() - { - foreach (var (process, _) in _processes) - { - try - { - process.Dispose(); - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // Best-effort dispose - } -#pragma warning restore CA1031 - } - - _processes.Clear(); - } - - private static int GetSafePid(Process process) - { - try - { - return process.Id; - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - return -1; - } -#pragma warning restore CA1031 - } - - private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) - { - if (_shutdownState == ShutdownPhase.Running) - { - // First Ctrl+C: graceful shutdown, cancel the default termination - e.Cancel = true; - GracefulShutdown(); - } - else - { - // Second Ctrl+C: force-kill immediately, let process terminate - AnsiConsole.MarkupLine("[red]Force-killing all processes...[/]"); - ForceKillAll(); - e.Cancel = false; - } - } - - private void OnProcessExit(object? sender, EventArgs e) - { - // Process is exiting (terminal closed, kill signal, etc.) - // Force-kill children to prevent orphans - ForceKillAll(); - } - - /// - /// Parse launchSettings.json to find the ports ASP.NET will bind to. - /// Falls back to the default ports (5001, 5000) if the file can't be read. - /// - private static List DiscoverDotnetPorts(string hostProjectPath) - { - var ports = new List(); - var hostDir = Path.GetDirectoryName(hostProjectPath); - if (hostDir is null) - { - return [5001, 5000]; - } - - var launchSettingsPath = Path.Combine(hostDir, "Properties", "launchSettings.json"); - - if (!File.Exists(launchSettingsPath)) - { - return [5001, 5000]; - } - - try - { - var json = File.ReadAllText(launchSettingsPath); - - // Extract applicationUrl values and parse ports from them. - // Format: "applicationUrl": "https://localhost:5001;http://localhost:5000" - // Use simple string parsing to avoid adding a JSON dependency to the CLI. - var searchKey = "\"applicationUrl\""; - var idx = json.IndexOf(searchKey, StringComparison.OrdinalIgnoreCase); - while (idx >= 0) - { - var colonIdx = json.IndexOf(':', idx + searchKey.Length); - if (colonIdx < 0) - { - break; - } - - var quoteStart = json.IndexOf('"', colonIdx + 1); - if (quoteStart < 0) - { - break; - } - - var quoteEnd = json.IndexOf('"', quoteStart + 1); - if (quoteEnd < 0) - { - break; - } - - var urlValue = json[(quoteStart + 1)..quoteEnd]; - foreach (var url in urlValue.Split(';')) - { - // Extract port from URL like "https://localhost:5001" - var lastColon = url.LastIndexOf(':'); - if ( - lastColon >= 0 - && int.TryParse( - url[(lastColon + 1)..], - System.Globalization.NumberStyles.Integer, - System.Globalization.CultureInfo.InvariantCulture, - out var port - ) - ) - { - if (!ports.Contains(port)) - { - ports.Add(port); - } - } - } - - idx = json.IndexOf(searchKey, quoteEnd + 1, StringComparison.OrdinalIgnoreCase); - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - // If we can't parse, fall back to defaults - } -#pragma warning restore CA1031 - - return ports.Count > 0 ? ports : [5001, 5000]; - } } diff --git a/cli/SimpleModule.Cli/Infrastructure/PortChecker.Finders.cs b/cli/SimpleModule.Cli/Infrastructure/PortChecker.Finders.cs new file mode 100644 index 00000000..2a20b1a1 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/PortChecker.Finders.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; + +namespace SimpleModule.Cli.Infrastructure; + +public static partial class PortChecker +{ + /// + /// Find the process listening on a given TCP port. + /// Returns null if the port is free. + /// + public static PortBlocker? FindProcessOnPort(int port) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return FindOnWindows(port); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return FindWithLsof(port); + } + + // Linux: try ss first (faster), fall back to lsof + return FindWithSs(port) ?? FindWithLsof(port); + } + + /// + /// Linux: use `ss -tlnp` to find the listener. + /// Output format: LISTEN 0 128 *:5001 *:* users:(("dotnet",pid=12345,fd=3)) + /// + private static PortBlocker? FindWithSs(int port) + { + var output = RunCommand("ss", $"-tlnp sport = :{port}"); + if (output is null) + { + return null; + } + + foreach (var line in output.Split('\n')) + { + if (!line.Contains($":{port}", StringComparison.Ordinal)) + { + continue; + } + + var pidIdx = line.IndexOf("pid=", StringComparison.Ordinal); + if (pidIdx < 0) + { + continue; + } + + var pidStart = pidIdx + 4; + var pidEnd = line.IndexOfAny([',', ')'], pidStart); + if (pidEnd < 0) + { + pidEnd = line.Length; + } + + var pidStr = line[pidStart..pidEnd]; + if ( + !int.TryParse( + pidStr, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var pid + ) + ) + { + continue; + } + + var nameStart = line.IndexOf("((\"", StringComparison.Ordinal); + var processName = "unknown"; + if (nameStart >= 0) + { + nameStart += 3; + var nameEnd = line.IndexOf('"', nameStart); + if (nameEnd > nameStart) + { + processName = line[nameStart..nameEnd]; + } + } + + return new PortBlocker(pid, processName); + } + + return null; + } + + /// + /// macOS / Linux fallback: use `lsof -iTCP:PORT -sTCP:LISTEN -nP`. + /// Output: dotnet 12345 user 3u IPv6 0x... 0t0 TCP *:5001 (LISTEN) + /// + private static PortBlocker? FindWithLsof(int port) + { + var output = RunCommand("lsof", $"-iTCP:{port} -sTCP:LISTEN -nP -t"); + if (output is null) + { + return null; + } + + var firstLine = output.Split('\n', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if ( + firstLine is null + || !int.TryParse( + firstLine.Trim(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var pid + ) + ) + { + return null; + } + + var processName = GetProcessName(pid) ?? "unknown"; + return new PortBlocker(pid, processName); + } + + /// + /// Windows: use `netstat -ano` to find the listener, then get process name. + /// Output: TCP 0.0.0.0:5001 0.0.0.0:0 LISTENING 12345 + /// + private static PortBlocker? FindOnWindows(int port) + { + var output = RunCommand("netstat", "-ano"); + if (output is null) + { + return null; + } + + var portSuffix = $":{port}"; + foreach (var line in output.Split('\n')) + { + var trimmed = line.Trim(); + if ( + !trimmed.Contains("LISTENING", StringComparison.OrdinalIgnoreCase) + || !trimmed.Contains(portSuffix, StringComparison.Ordinal) + ) + { + continue; + } + + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 5) + { + continue; + } + + if (!parts[1].EndsWith(portSuffix, StringComparison.Ordinal)) + { + continue; + } + + var pidStr = parts[^1]; + if ( + !int.TryParse( + pidStr, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var pid + ) + ) + { + continue; + } + + var processName = GetProcessName(pid) ?? "unknown"; + return new PortBlocker(pid, processName); + } + + return null; + } + + private static string? GetProcessName(int pid) + { + try + { + using var proc = Process.GetProcessById(pid); + return proc.ProcessName; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + return null; + } +#pragma warning restore CA1031 + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/PortChecker.cs b/cli/SimpleModule.Cli/Infrastructure/PortChecker.cs index 08e0af01..58cd88e4 100644 --- a/cli/SimpleModule.Cli/Infrastructure/PortChecker.cs +++ b/cli/SimpleModule.Cli/Infrastructure/PortChecker.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.Globalization; -using System.Runtime.InteropServices; using Spectre.Console; namespace SimpleModule.Cli.Infrastructure; @@ -9,7 +7,7 @@ namespace SimpleModule.Cli.Infrastructure; /// Cross-platform utility to check if a TCP port is in use and optionally /// kill the process occupying it. /// -public static class PortChecker +public static partial class PortChecker { /// /// Check if a port is in use. If it is, display the blocking process @@ -47,7 +45,6 @@ public static bool EnsurePortFree(int port, string serviceName) // Wait briefly for the port to be released Thread.Sleep(500); - // Verify port is now free var stillBlocked = FindProcessOnPort(port); if (stillBlocked is null) { @@ -70,195 +67,6 @@ public static bool EnsurePortFree(int port, string serviceName) return false; } - /// - /// Find the process listening on a given TCP port. - /// Returns null if the port is free. - /// - public static PortBlocker? FindProcessOnPort(int port) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return FindOnWindows(port); - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return FindWithLsof(port); - } - - // Linux: try ss first (faster), fall back to lsof - return FindWithSs(port) ?? FindWithLsof(port); - } - - /// - /// Linux: use `ss -tlnp` to find the listener. - /// Output format: LISTEN 0 128 *:5001 *:* users:(("dotnet",pid=12345,fd=3)) - /// - private static PortBlocker? FindWithSs(int port) - { - var output = RunCommand("ss", $"-tlnp sport = :{port}"); - if (output is null) - { - return null; - } - - // Parse lines looking for pid=NNNN and the process name - foreach (var line in output.Split('\n')) - { - if (!line.Contains($":{port}", StringComparison.Ordinal)) - { - continue; - } - - // Extract pid from users:(("name",pid=NNN,...)) - var pidIdx = line.IndexOf("pid=", StringComparison.Ordinal); - if (pidIdx < 0) - { - continue; - } - - var pidStart = pidIdx + 4; - var pidEnd = line.IndexOfAny([',', ')'], pidStart); - if (pidEnd < 0) - { - pidEnd = line.Length; - } - - var pidStr = line[pidStart..pidEnd]; - if ( - !int.TryParse( - pidStr, - NumberStyles.Integer, - CultureInfo.InvariantCulture, - out var pid - ) - ) - { - continue; - } - - // Extract process name from (("name",...)) - var nameStart = line.IndexOf("((\"", StringComparison.Ordinal); - var processName = "unknown"; - if (nameStart >= 0) - { - nameStart += 3; - var nameEnd = line.IndexOf('"', nameStart); - if (nameEnd > nameStart) - { - processName = line[nameStart..nameEnd]; - } - } - - return new PortBlocker(pid, processName); - } - - return null; - } - - /// - /// macOS / Linux fallback: use `lsof -iTCP:PORT -sTCP:LISTEN -nP`. - /// Output: dotnet 12345 user 3u IPv6 0x... 0t0 TCP *:5001 (LISTEN) - /// - private static PortBlocker? FindWithLsof(int port) - { - var output = RunCommand("lsof", $"-iTCP:{port} -sTCP:LISTEN -nP -t"); - if (output is null) - { - return null; - } - - // -t flag outputs just PIDs, one per line - var firstLine = output.Split('\n', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); - if ( - firstLine is null - || !int.TryParse( - firstLine.Trim(), - NumberStyles.Integer, - CultureInfo.InvariantCulture, - out var pid - ) - ) - { - return null; - } - - var processName = GetProcessName(pid) ?? "unknown"; - return new PortBlocker(pid, processName); - } - - /// - /// Windows: use `netstat -ano` to find the listener, then get process name. - /// Output: TCP 0.0.0.0:5001 0.0.0.0:0 LISTENING 12345 - /// - private static PortBlocker? FindOnWindows(int port) - { - var output = RunCommand("netstat", "-ano"); - if (output is null) - { - return null; - } - - var portSuffix = $":{port}"; - foreach (var line in output.Split('\n')) - { - var trimmed = line.Trim(); - if ( - !trimmed.Contains("LISTENING", StringComparison.OrdinalIgnoreCase) - || !trimmed.Contains(portSuffix, StringComparison.Ordinal) - ) - { - continue; - } - - // Split by whitespace, last field is PID - var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length < 5) - { - continue; - } - - // Verify the port is in the local address column (2nd field) - if (!parts[1].EndsWith(portSuffix, StringComparison.Ordinal)) - { - continue; - } - - var pidStr = parts[^1]; - if ( - !int.TryParse( - pidStr, - NumberStyles.Integer, - CultureInfo.InvariantCulture, - out var pid - ) - ) - { - continue; - } - - var processName = GetProcessName(pid) ?? "unknown"; - return new PortBlocker(pid, processName); - } - - return null; - } - - private static string? GetProcessName(int pid) - { - try - { - using var proc = Process.GetProcessById(pid); - return proc.ProcessName; - } -#pragma warning disable CA1031 // Do not catch general exception types - catch - { - return null; - } -#pragma warning restore CA1031 - } - private static bool KillProcess(int pid) { try diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.Helpers.cs b/framework/SimpleModule.DevTools/ViteDevWatchService.Helpers.cs new file mode 100644 index 00000000..bbf2b7c2 --- /dev/null +++ b/framework/SimpleModule.DevTools/ViteDevWatchService.Helpers.cs @@ -0,0 +1,93 @@ +using System.Runtime.InteropServices; + +namespace SimpleModule.DevTools; + +public sealed partial class ViteDevWatchService +{ + internal static string? FindRepoRoot(string startPath) + { + var current = startPath; + while (current is not null) + { + var gitPath = Path.Combine(current, ".git"); + // .git is a directory in normal repos, but a file in worktrees + if (Directory.Exists(gitPath) || File.Exists(gitPath)) + { + return current; + } + + current = Path.GetDirectoryName(current); + } + + return null; + } + + internal static List DiscoverModuleDirectories(string modulesRoot) + { + var directories = new List(); + + if (!Directory.Exists(modulesRoot)) + { + return directories; + } + + foreach (var moduleGroupDir in Directory.GetDirectories(modulesRoot)) + { + var srcDir = Path.Combine(moduleGroupDir, "src"); + if (!Directory.Exists(srcDir)) + { + continue; + } + + foreach (var moduleDir in Directory.GetDirectories(srcDir)) + { + if (File.Exists(Path.Combine(moduleDir, "vite.config.ts"))) + { + directories.Add(moduleDir); + } + } + } + + return directories; + } + + internal static bool ShouldIgnoreModulePath(string fullPath) + { + return ContainsSegment(fullPath, "wwwroot") || ContainsSegment(fullPath, "node_modules"); + } + + internal static bool ShouldIgnoreClientAppPath(string fullPath) + { + return ContainsSegment(fullPath, "node_modules"); + } + + internal static bool ShouldIgnoreTailwindPath(string fullPath) + { + return ContainsSegment(fullPath, "_scan"); + } + + internal static bool ContainsSegment(string fullPath, string segment) + { + // Check both separator styles for cross-platform path matching + return fullPath.Contains( + $"{Path.DirectorySeparatorChar}{segment}{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase + ) + || fullPath.Contains( + $"{Path.AltDirectorySeparatorChar}{segment}{Path.AltDirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase + ); + } + + private static string GetShellFileName() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : "sh"; + } + + private static string GetShellArguments(string command) + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? $"/c {command}" + : $"-c \"{command}\""; + } +} diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.Logging.cs b/framework/SimpleModule.DevTools/ViteDevWatchService.Logging.cs new file mode 100644 index 00000000..bea53844 --- /dev/null +++ b/framework/SimpleModule.DevTools/ViteDevWatchService.Logging.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Logging; + +namespace SimpleModule.DevTools; + +public sealed partial class ViteDevWatchService +{ + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not find repository root (.git directory). ViteDevWatchService will not start." + )] + private static partial void LogRepoRootNotFound(ILogger logger); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "ViteDevWatchService starting. Repo root: {RepoRoot}" + )] + private static partial void LogServiceStarting(ILogger logger, string repoRoot); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "ViteDevWatchService watching {ModuleCount} module(s), ClientApp, and Styles" + )] + private static partial void LogWatchingStarted(ILogger logger, int moduleCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "{BuildKey} file changed: {FilePath}")] + private static partial void LogFileChanged(ILogger logger, string buildKey, string? filePath); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Watching module: {ModuleName} at {ModuleDir}" + )] + private static partial void LogWatchingModule( + ILogger logger, + string moduleName, + string moduleDir + ); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Watching ClientApp at {ClientAppDir}")] + private static partial void LogWatchingClientApp(ILogger logger, string clientAppDir); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Watching Styles at {StylesDir}")] + private static partial void LogWatchingStyles(ILogger logger, string stylesDir); + + [LoggerMessage(Level = LogLevel.Information, Message = "Rebuilding {Name}...")] + private static partial void LogRebuilding(ILogger logger, string name); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "{Name} rebuilt successfully in {ElapsedMs}ms" + )] + private static partial void LogRebuiltSuccessfully(ILogger logger, string name, long elapsedMs); + + [LoggerMessage(Level = LogLevel.Error, Message = "{Name} build failed")] + private static partial void LogBuildFailed(ILogger logger, string name); + + [LoggerMessage(Level = LogLevel.Error, Message = "Build stderr: {Stderr}")] + private static partial void LogBuildStderr(ILogger logger, string stderr); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Build stdout: {Stdout}")] + private static partial void LogBuildStdout(ILogger logger, string stdout); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Build output: {Stdout}")] + private static partial void LogBuildOutput(ILogger logger, string stdout); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Build cancelled")] + private static partial void LogBuildCancelled(ILogger logger); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Failed to run build process: {FileName} {Arguments}" + )] + private static partial void LogBuildProcessFailed( + ILogger logger, + Exception ex, + string fileName, + string arguments + ); +} diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.Process.cs b/framework/SimpleModule.DevTools/ViteDevWatchService.Process.cs new file mode 100644 index 00000000..1aacd485 --- /dev/null +++ b/framework/SimpleModule.DevTools/ViteDevWatchService.Process.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; + +namespace SimpleModule.DevTools; + +public sealed partial class ViteDevWatchService +{ + private async Task RunProcessAsync( + string fileName, + string arguments, + string workingDirectory, + CancellationToken stoppingToken + ) + { + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + process.StartInfo.Environment["VITE_MODE"] = "dev"; + + var existingPath = process.StartInfo.Environment.TryGetValue("PATH", out var path) + ? path + : ""; + process.StartInfo.Environment["PATH"] = + $"{_npmBinPath}{Path.PathSeparator}{existingPath}"; + + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(stoppingToken); + var stderrTask = process.StandardError.ReadToEndAsync(stoppingToken); + + await process.WaitForExitAsync(stoppingToken).ConfigureAwait(false); + + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var trimmedStderr = stderr.Trim(); + if (trimmedStderr.Length > 0) + { + LogBuildStderr(logger, trimmedStderr); + } + + var trimmedStdout = stdout.Trim(); + if (trimmedStdout.Length > 0) + { + LogBuildStdout(logger, trimmedStdout); + } + + return false; + } + + var trimmedOutput = stdout.Trim(); + if (trimmedOutput.Length > 0) + { + LogBuildOutput(logger, trimmedOutput); + } + + return true; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + LogBuildCancelled(logger); + return false; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + LogBuildProcessFailed(logger, ex, fileName, arguments); + return false; + } + } +} diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.cs b/framework/SimpleModule.DevTools/ViteDevWatchService.cs index e6bbf525..5321ee2b 100644 --- a/framework/SimpleModule.DevTools/ViteDevWatchService.cs +++ b/framework/SimpleModule.DevTools/ViteDevWatchService.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.InteropServices; using Microsoft.Extensions.Hosting; @@ -253,245 +253,4 @@ await Task.Delay(TimeSpan.FromMilliseconds(300), cts.Token) _stoppingToken ); } - - private async Task RunProcessAsync( - string fileName, - string arguments, - string workingDirectory, - CancellationToken stoppingToken - ) - { - try - { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - WorkingDirectory = workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - process.StartInfo.Environment["VITE_MODE"] = "dev"; - - var existingPath = process.StartInfo.Environment.TryGetValue("PATH", out var path) - ? path - : ""; - process.StartInfo.Environment["PATH"] = - $"{_npmBinPath}{Path.PathSeparator}{existingPath}"; - - process.Start(); - - var stdoutTask = process.StandardOutput.ReadToEndAsync(stoppingToken); - var stderrTask = process.StandardError.ReadToEndAsync(stoppingToken); - - await process.WaitForExitAsync(stoppingToken).ConfigureAwait(false); - - var stdout = await stdoutTask.ConfigureAwait(false); - var stderr = await stderrTask.ConfigureAwait(false); - - if (process.ExitCode != 0) - { - var trimmedStderr = stderr.Trim(); - if (trimmedStderr.Length > 0) - { - LogBuildStderr(logger, trimmedStderr); - } - - var trimmedStdout = stdout.Trim(); - if (trimmedStdout.Length > 0) - { - LogBuildStdout(logger, trimmedStdout); - } - - return false; - } - - var trimmedOutput = stdout.Trim(); - if (trimmedOutput.Length > 0) - { - LogBuildOutput(logger, trimmedOutput); - } - - return true; - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - LogBuildCancelled(logger); - return false; - } -#pragma warning disable CA1031 // Do not catch general exception types - catch (Exception ex) -#pragma warning restore CA1031 - { - LogBuildProcessFailed(logger, ex, fileName, arguments); - return false; - } - } - - internal static string? FindRepoRoot(string startPath) - { - var current = startPath; - while (current is not null) - { - var gitPath = Path.Combine(current, ".git"); - // .git is a directory in normal repos, but a file in worktrees - if (Directory.Exists(gitPath) || File.Exists(gitPath)) - { - return current; - } - - current = Path.GetDirectoryName(current); - } - - return null; - } - - internal static List DiscoverModuleDirectories(string modulesRoot) - { - var directories = new List(); - - if (!Directory.Exists(modulesRoot)) - { - return directories; - } - - foreach (var moduleGroupDir in Directory.GetDirectories(modulesRoot)) - { - var srcDir = Path.Combine(moduleGroupDir, "src"); - if (!Directory.Exists(srcDir)) - { - continue; - } - - foreach (var moduleDir in Directory.GetDirectories(srcDir)) - { - if (File.Exists(Path.Combine(moduleDir, "vite.config.ts"))) - { - directories.Add(moduleDir); - } - } - } - - return directories; - } - - internal static bool ShouldIgnoreModulePath(string fullPath) - { - return ContainsSegment(fullPath, "wwwroot") || ContainsSegment(fullPath, "node_modules"); - } - - internal static bool ShouldIgnoreClientAppPath(string fullPath) - { - return ContainsSegment(fullPath, "node_modules"); - } - - internal static bool ShouldIgnoreTailwindPath(string fullPath) - { - return ContainsSegment(fullPath, "_scan"); - } - - internal static bool ContainsSegment(string fullPath, string segment) - { - // Check both separator styles for cross-platform path matching - return fullPath.Contains( - $"{Path.DirectorySeparatorChar}{segment}{Path.DirectorySeparatorChar}", - StringComparison.OrdinalIgnoreCase - ) - || fullPath.Contains( - $"{Path.AltDirectorySeparatorChar}{segment}{Path.AltDirectorySeparatorChar}", - StringComparison.OrdinalIgnoreCase - ); - } - - private static string GetShellFileName() - { - return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd" : "sh"; - } - - private static string GetShellArguments(string command) - { - return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? $"/c {command}" - : $"-c \"{command}\""; - } - - #region LoggerMessage definitions - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Could not find repository root (.git directory). ViteDevWatchService will not start." - )] - private static partial void LogRepoRootNotFound(ILogger logger); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "ViteDevWatchService starting. Repo root: {RepoRoot}" - )] - private static partial void LogServiceStarting(ILogger logger, string repoRoot); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "ViteDevWatchService watching {ModuleCount} module(s), ClientApp, and Styles" - )] - private static partial void LogWatchingStarted(ILogger logger, int moduleCount); - - [LoggerMessage(Level = LogLevel.Debug, Message = "{BuildKey} file changed: {FilePath}")] - private static partial void LogFileChanged(ILogger logger, string buildKey, string? filePath); - - [LoggerMessage( - Level = LogLevel.Debug, - Message = "Watching module: {ModuleName} at {ModuleDir}" - )] - private static partial void LogWatchingModule( - ILogger logger, - string moduleName, - string moduleDir - ); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Watching ClientApp at {ClientAppDir}")] - private static partial void LogWatchingClientApp(ILogger logger, string clientAppDir); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Watching Styles at {StylesDir}")] - private static partial void LogWatchingStyles(ILogger logger, string stylesDir); - - [LoggerMessage(Level = LogLevel.Information, Message = "Rebuilding {Name}...")] - private static partial void LogRebuilding(ILogger logger, string name); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "{Name} rebuilt successfully in {ElapsedMs}ms" - )] - private static partial void LogRebuiltSuccessfully(ILogger logger, string name, long elapsedMs); - - [LoggerMessage(Level = LogLevel.Error, Message = "{Name} build failed")] - private static partial void LogBuildFailed(ILogger logger, string name); - - [LoggerMessage(Level = LogLevel.Error, Message = "Build stderr: {Stderr}")] - private static partial void LogBuildStderr(ILogger logger, string stderr); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Build stdout: {Stdout}")] - private static partial void LogBuildStdout(ILogger logger, string stdout); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Build output: {Stdout}")] - private static partial void LogBuildOutput(ILogger logger, string stdout); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Build cancelled")] - private static partial void LogBuildCancelled(ILogger logger); - - [LoggerMessage( - Level = LogLevel.Error, - Message = "Failed to run build process: {FileName} {Arguments}" - )] - private static partial void LogBuildProcessFailed( - ILogger logger, - Exception ex, - string fileName, - string arguments - ); - - #endregion } diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.Helpers.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.Helpers.cs new file mode 100644 index 00000000..f2bf48b5 --- /dev/null +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.Helpers.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SimpleModule.Core.Constants; +using SimpleModule.Core.Menu; +using SimpleModule.Database; + +namespace SimpleModule.Hosting; + +public static partial class SimpleModuleHostExtensions +{ + private const int ImmutableAssetsCacheDurationSeconds = 31536000; // 365 days + private const string VendorJsPathPrefix = "/js/vendor/"; + private const string ModuleContentPathPrefix = "/_content/"; + private const string ModuleScriptExtension = ".mjs"; + + private static IResult RenderErrorPage(int statusCode) + { + var (title, message) = statusCode switch + { + 403 => (ErrorMessages.ForbiddenTitle, ErrorMessages.DefaultForbiddenMessage), + 404 => (ErrorMessages.NotFoundTitle, ErrorMessages.DefaultNotFoundMessage), + _ => (ErrorMessages.InternalServerErrorTitle, ErrorMessages.UnexpectedError), + }; + + return SimpleModule.Core.Inertia.Inertia.Render( + $"Error/{statusCode}", + new + { + status = statusCode, + title, + message, + } + ); + } + + private static void BridgeAspireConnectionString(ConfigurationManager configuration) + { + var aspireConnectionString = configuration.GetConnectionString("simplemoduledb"); + if (!string.IsNullOrEmpty(aspireConnectionString)) + { + configuration["Database:DefaultConnection"] = aspireConnectionString; + } + } + + private static DatabaseProvider ValidateDatabaseConfiguration( + ConfigurationManager configuration + ) + { + var dbOptions = + configuration.GetSection(DatabaseConstants.SectionName).Get() + ?? new DatabaseOptions(); + var connString = dbOptions.DefaultConnection; + + if (string.IsNullOrEmpty(connString)) + { + throw new InvalidOperationException( + "Database configuration is missing. " + + "Ensure 'Database:DefaultConnection' is configured in appsettings.json." + ); + } + + return DatabaseProviderDetector.Detect(connString, dbOptions.Provider); + } + + private static void UseStaticFileCaching(WebApplication app) + { + app.Use( + async (context, next) => + { + var path = context.Request.Path.Value; + + if (string.IsNullOrEmpty(path)) + { + await next(); + return; + } + + bool hasVersionParam = context.Request.Query.ContainsKey("v"); + bool isVendorJs = path.StartsWith( + VendorJsPathPrefix, + StringComparison.OrdinalIgnoreCase + ); + bool isHashedChunk = + path.StartsWith(ModuleContentPathPrefix, StringComparison.OrdinalIgnoreCase) + && path.EndsWith(ModuleScriptExtension, StringComparison.OrdinalIgnoreCase); + + if (hasVersionParam || isVendorJs || isHashedChunk) + { + context.Response.OnStarting(() => + { + context.Response.Headers.CacheControl = + $"public, max-age={ImmutableAssetsCacheDurationSeconds}, immutable"; + return Task.CompletedTask; + }); + } + + await next(); + } + ); + } + + private static void UseHomePageRewrite(WebApplication app) + { + app.Use( + async (context, next) => + { + if (context.Request.Path == "/" && HttpMethods.IsGet(context.Request.Method)) + { + var menuProvider = context.RequestServices.GetService(); + if (menuProvider is not null) + { + var homeUrl = await menuProvider.GetHomePageUrlAsync(); + if (homeUrl is not null && homeUrl != "/") + { + context.Request.Path = homeUrl; + } + } + } + + await next(); + } + ); + } + + private static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report) + { + context.Response.ContentType = "application/json"; + + var entries = report + .Entries.Select(e => new + { + name = e.Key, + status = e.Value.Status.ToString(), + description = e.Value.Description, + data = e.Value.Data, + }) + .ToList(); + + var response = new + { + status = report.Status.ToString(), + totalDuration = report.TotalDuration.TotalMilliseconds, + checks = entries, + }; + + await context.Response.WriteAsJsonAsync(response); + } +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 79cbee28..6454c40a 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -27,13 +27,8 @@ namespace SimpleModule.Hosting; -public static class SimpleModuleHostExtensions +public static partial class SimpleModuleHostExtensions { - private const int ImmutableAssetsCacheDurationSeconds = 31536000; // 365 days - private const string VendorJsPathPrefix = "/js/vendor/"; - private const string ModuleContentPathPrefix = "/_content/"; - private const string ModuleScriptExtension = ".mjs"; - /// /// Registers all non-generated SimpleModule infrastructure services. /// Called by the source-generated AddSimpleModule() method. @@ -299,140 +294,4 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) .AllowAnonymous() .ExcludeFromDescription(); } - - private static IResult RenderErrorPage(int statusCode) - { - var (title, message) = statusCode switch - { - 403 => (ErrorMessages.ForbiddenTitle, ErrorMessages.DefaultForbiddenMessage), - 404 => (ErrorMessages.NotFoundTitle, ErrorMessages.DefaultNotFoundMessage), - _ => (ErrorMessages.InternalServerErrorTitle, ErrorMessages.UnexpectedError), - }; - - return SimpleModule.Core.Inertia.Inertia.Render( - $"Error/{statusCode}", - new - { - status = statusCode, - title, - message, - } - ); - } - - private static void BridgeAspireConnectionString(ConfigurationManager configuration) - { - var aspireConnectionString = configuration.GetConnectionString("simplemoduledb"); - if (!string.IsNullOrEmpty(aspireConnectionString)) - { - configuration["Database:DefaultConnection"] = aspireConnectionString; - } - } - - private static DatabaseProvider ValidateDatabaseConfiguration( - ConfigurationManager configuration - ) - { - var dbOptions = - configuration.GetSection(DatabaseConstants.SectionName).Get() - ?? new DatabaseOptions(); - var connString = dbOptions.DefaultConnection; - - if (string.IsNullOrEmpty(connString)) - { - throw new InvalidOperationException( - "Database configuration is missing. " - + "Ensure 'Database:DefaultConnection' is configured in appsettings.json." - ); - } - - return DatabaseProviderDetector.Detect(connString, dbOptions.Provider); - } - - private static void UseStaticFileCaching(WebApplication app) - { - app.Use( - async (context, next) => - { - var path = context.Request.Path.Value; - - if (string.IsNullOrEmpty(path)) - { - await next(); - return; - } - - bool hasVersionParam = context.Request.Query.ContainsKey("v"); - bool isVendorJs = path.StartsWith( - VendorJsPathPrefix, - StringComparison.OrdinalIgnoreCase - ); - bool isHashedChunk = - path.StartsWith(ModuleContentPathPrefix, StringComparison.OrdinalIgnoreCase) - && path.EndsWith(ModuleScriptExtension, StringComparison.OrdinalIgnoreCase); - - if (hasVersionParam || isVendorJs || isHashedChunk) - { - context.Response.OnStarting(() => - { - context.Response.Headers.CacheControl = - $"public, max-age={ImmutableAssetsCacheDurationSeconds}, immutable"; - return Task.CompletedTask; - }); - } - - await next(); - } - ); - } - - private static void UseHomePageRewrite(WebApplication app) - { - app.Use( - async (context, next) => - { - if (context.Request.Path == "/" && HttpMethods.IsGet(context.Request.Method)) - { - var menuProvider = context.RequestServices.GetService(); - if (menuProvider is not null) - { - var homeUrl = await menuProvider.GetHomePageUrlAsync(); - if (homeUrl is not null && homeUrl != "/") - { - context.Request.Path = homeUrl; - } - } - } - - await next(); - } - ); - } - - private static async Task WriteHealthCheckResponse( - HttpContext context, - Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report - ) - { - context.Response.ContentType = "application/json"; - - var entries = report - .Entries.Select(e => new - { - name = e.Key, - status = e.Value.Status.ToString(), - description = e.Value.Description, - data = e.Value.Data, - }) - .ToList(); - - var response = new - { - status = report.Status.ToString(), - totalDuration = report.TotalDuration.TotalMilliseconds, - checks = entries, - }; - - await context.Response.WriteAsJsonAsync(response); - } } diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx index e8736fe1..0d19be48 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx @@ -9,11 +9,6 @@ import { BreadcrumbPage, BreadcrumbSeparator, Button, - Card, - CardContent, - CardHeader, - CardTitle, - Checkbox, Container, Dialog, DialogContent, @@ -21,21 +16,14 @@ import { DialogFooter, DialogHeader, DialogTitle, - Field, - FieldGroup, - Input, - Label, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, } from '@simplemodule/ui'; import { useState } from 'react'; -import { PermissionGroups } from '@/components/PermissionGroups'; import { TabNav } from '@/components/TabNav'; import { AdminKeys } from '@/Locales/keys'; +import { UserDetailsTab } from './components/UserDetailsTab'; +import { UserRolesTab } from './components/UserRolesTab'; +import { UserSecurityTab } from './components/UserSecurityTab'; +import { UserSessionsTab } from './components/UserSessionsTab'; interface UserDetail { id: string; @@ -88,7 +76,6 @@ export default function UsersEdit({ }: Props) { const { t } = useTranslation('Admin'); const [confirmAction, setConfirmAction] = useState(null); - const [passwordError, setPasswordError] = useState(null); const isSelf = user.id === currentUserId; @@ -174,366 +161,38 @@ export default function UsersEdit({ {tab === 'details' && ( - <> - - - {t(AdminKeys.UsersEdit.DetailsTitle)} - - -
{ - e.preventDefault(); - router.post(`/admin/users/${user.id}`, new FormData(e.currentTarget)); - }} - > - - - - - - - - - - - - - - - -
-
-
- - - - {t(AdminKeys.UsersEdit.AccountStatusTitle)} - - - {user.isDeactivated ? ( -
-

- {t(AdminKeys.UsersEdit.AccountDeactivatedMessage)} -

- -
- ) : isSelf ? ( -

- {t(AdminKeys.UsersEdit.CannotDeactivateSelf)} -

- ) : ( -
-

- {t(AdminKeys.UsersEdit.DeactivateWarning)} -

- -
- )} -
-
- + setConfirmAction('deactivate')} + /> )} {tab === 'roles' && ( - <> - - - {t(AdminKeys.UsersEdit.RolesTitle)} - - -
{ - e.preventDefault(); - router.post(`/admin/users/${user.id}/roles`, new FormData(e.currentTarget)); - }} - > -
- {allRoles.map((role) => ( -
- - -
- ))} - {allRoles.length === 0 && ( -

- {t(AdminKeys.UsersEdit.NoRolesDefined)} -

- )} -
- -
-
-
- - - - {t(AdminKeys.UsersEdit.DirectPermissionsTitle)} - - -

- {t(AdminKeys.UsersEdit.DirectPermissionsDescription)} -

-
{ - e.preventDefault(); - router.post(`/admin/users/${user.id}/permissions`, new FormData(e.currentTarget)); - }} - > - - - -
-
- + )} {tab === 'security' && ( - <> - - - {t(AdminKeys.UsersEdit.ResetPasswordTitle)} - - -
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - if (formData.get('newPassword') !== formData.get('confirmPassword')) { - setPasswordError(t(AdminKeys.UsersEdit.ErrorPasswordMismatch)); - return; - } - setPasswordError(null); - router.post(`/admin/users/${user.id}/reset-password`, formData); - }} - > - - {passwordError && ( -
- {passwordError} -
- )} - - - - - - - - - -
-
-
-
- - - - {t(AdminKeys.UsersEdit.AccountLockTitle)} - - - {user.isLockedOut ? ( -
-

- {t(AdminKeys.UsersEdit.AccountLockedMessage)} -

- -
- ) : isSelf ? ( -

{t(AdminKeys.UsersEdit.CannotLockSelf)}

- ) : ( -
-

- {t(AdminKeys.UsersEdit.AccountActiveMessage)} -

- -
- )} -
-
- - - - {t(AdminKeys.UsersEdit.EmailVerificationTitle)} - - -

- {t(AdminKeys.UsersEdit.EmailVerificationStatus, { - status: user.emailConfirmed - ? t(AdminKeys.UsersEdit.EmailVerified) - : t(AdminKeys.UsersEdit.EmailNotVerified), - })} -

- {user.emailConfirmed && ( - - )} -
-
- - - - {t(AdminKeys.UsersEdit.TwoFactorTitle)} - - -

- {t(AdminKeys.UsersEdit.TwoFactorStatus, { - status: user.twoFactorEnabled - ? t(AdminKeys.UsersEdit.TwoFactorEnabled) - : t(AdminKeys.UsersEdit.TwoFactorNotEnabled), - })} -

- {user.twoFactorEnabled && ( - - )} -
-
- - - - {t(AdminKeys.UsersEdit.LoginInfoTitle)} - - -
-
- - {t(AdminKeys.UsersEdit.FailedLoginAttempts)} - - {user.accessFailedCount} -
-
- {t(AdminKeys.UsersEdit.LastLogin)} - - {user.lastLoginAt - ? new Date(user.lastLoginAt).toLocaleString() - : t(AdminKeys.UsersEdit.LastLoginNever)} - -
-
- {t(AdminKeys.UsersEdit.CreatedAt)} - - {new Date(user.createdAt).toLocaleString()} - -
-
-
-
- + setConfirmAction('reverify')} + onDisable2fa={() => setConfirmAction('disable2fa')} + /> )} {tab === 'sessions' && ( - - -
- {t(AdminKeys.UsersEdit.ActiveSessionsTitle)} - {activeSessions.length > 0 && ( - - )} -
-
- - {activeSessions.length === 0 ? ( -

{t(AdminKeys.UsersEdit.NoActiveSessions)}

- ) : ( -
- - - - {t(AdminKeys.UsersEdit.ColType)} - {t(AdminKeys.UsersEdit.ColApplication)} - {t(AdminKeys.UsersEdit.ColCreated)} - {t(AdminKeys.UsersEdit.ColExpires)} - - - - - {activeSessions.map((session) => ( - - - - {session.type === 'refresh_token' - ? t(AdminKeys.UsersEdit.SessionTypeRefresh) - : t(AdminKeys.UsersEdit.SessionTypeAccess)} - - - - {session.applicationName || '\u2014'} - - - {session.creationDate - ? new Date(session.creationDate).toLocaleString() - : '\u2014'} - - - {session.expirationDate - ? new Date(session.expirationDate).toLocaleString() - : t(AdminKeys.UsersEdit.SessionExpiresNever)} - - - - - - ))} - -
-
- )} -
-
+ setConfirmAction('revokeAll')} + /> )} void; +} + +export function UserDetailsTab({ user, isSelf, onDeactivate }: Props) { + const { t } = useTranslation('Admin'); + + return ( + <> + + + {t(AdminKeys.UsersEdit.DetailsTitle)} + + +
{ + e.preventDefault(); + router.post(`/admin/users/${user.id}`, new FormData(e.currentTarget)); + }} + > + + + + + + + + + + + + + + + +
+
+
+ + + + {t(AdminKeys.UsersEdit.AccountStatusTitle)} + + + {user.isDeactivated ? ( +
+

+ {t(AdminKeys.UsersEdit.AccountDeactivatedMessage)} +

+ +
+ ) : isSelf ? ( +

{t(AdminKeys.UsersEdit.CannotDeactivateSelf)}

+ ) : ( +
+

+ {t(AdminKeys.UsersEdit.DeactivateWarning)} +

+ +
+ )} +
+
+ + ); +} diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserRolesTab.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserRolesTab.tsx new file mode 100644 index 00000000..803d56d6 --- /dev/null +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserRolesTab.tsx @@ -0,0 +1,104 @@ +import { router } from '@inertiajs/react'; +import { useTranslation } from '@simplemodule/client/use-translation'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Checkbox, + Label, +} from '@simplemodule/ui'; +import { PermissionGroups } from '@/components/PermissionGroups'; +import { AdminKeys } from '@/Locales/keys'; + +interface Role { + id: string; + name: string; + description: string | null; +} + +interface Props { + userId: string; + userRoles: string[]; + allRoles: Role[]; + userPermissions: string[]; + permissionsByModule: Record; +} + +export function UserRolesTab({ + userId, + userRoles, + allRoles, + userPermissions, + permissionsByModule, +}: Props) { + const { t } = useTranslation('Admin'); + + return ( + <> + + + {t(AdminKeys.UsersEdit.RolesTitle)} + + +
{ + e.preventDefault(); + router.post(`/admin/users/${userId}/roles`, new FormData(e.currentTarget)); + }} + > +
+ {allRoles.map((role) => ( +
+ + +
+ ))} + {allRoles.length === 0 && ( +

{t(AdminKeys.UsersEdit.NoRolesDefined)}

+ )} +
+ +
+
+
+ + + + {t(AdminKeys.UsersEdit.DirectPermissionsTitle)} + + +

+ {t(AdminKeys.UsersEdit.DirectPermissionsDescription)} +

+
{ + e.preventDefault(); + router.post(`/admin/users/${userId}/permissions`, new FormData(e.currentTarget)); + }} + > + + + +
+
+ + ); +} diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSecurityTab.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSecurityTab.tsx new file mode 100644 index 00000000..cf33d235 --- /dev/null +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSecurityTab.tsx @@ -0,0 +1,178 @@ +import { router } from '@inertiajs/react'; +import { useTranslation } from '@simplemodule/client/use-translation'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Field, + FieldGroup, + Input, + Label, +} from '@simplemodule/ui'; +import { useState } from 'react'; +import { AdminKeys } from '@/Locales/keys'; + +interface UserDetail { + id: string; + emailConfirmed: boolean; + twoFactorEnabled: boolean; + isLockedOut: boolean; + accessFailedCount: number; + createdAt: string; + lastLoginAt: string | null; +} + +interface Props { + user: UserDetail; + isSelf: boolean; + onReverify: () => void; + onDisable2fa: () => void; +} + +export function UserSecurityTab({ user, isSelf, onReverify, onDisable2fa }: Props) { + const { t } = useTranslation('Admin'); + const [passwordError, setPasswordError] = useState(null); + + return ( + <> + + + {t(AdminKeys.UsersEdit.ResetPasswordTitle)} + + +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + if (formData.get('newPassword') !== formData.get('confirmPassword')) { + setPasswordError(t(AdminKeys.UsersEdit.ErrorPasswordMismatch)); + return; + } + setPasswordError(null); + router.post(`/admin/users/${user.id}/reset-password`, formData); + }} + > + + {passwordError && ( +
+ {passwordError} +
+ )} + + + + + + + + + +
+
+
+
+ + + + {t(AdminKeys.UsersEdit.AccountLockTitle)} + + + {user.isLockedOut ? ( +
+

+ {t(AdminKeys.UsersEdit.AccountLockedMessage)} +

+ +
+ ) : isSelf ? ( +

{t(AdminKeys.UsersEdit.CannotLockSelf)}

+ ) : ( +
+

+ {t(AdminKeys.UsersEdit.AccountActiveMessage)} +

+ +
+ )} +
+
+ + + + {t(AdminKeys.UsersEdit.EmailVerificationTitle)} + + +

+ {t(AdminKeys.UsersEdit.EmailVerificationStatus, { + status: user.emailConfirmed + ? t(AdminKeys.UsersEdit.EmailVerified) + : t(AdminKeys.UsersEdit.EmailNotVerified), + })} +

+ {user.emailConfirmed && ( + + )} +
+
+ + + + {t(AdminKeys.UsersEdit.TwoFactorTitle)} + + +

+ {t(AdminKeys.UsersEdit.TwoFactorStatus, { + status: user.twoFactorEnabled + ? t(AdminKeys.UsersEdit.TwoFactorEnabled) + : t(AdminKeys.UsersEdit.TwoFactorNotEnabled), + })} +

+ {user.twoFactorEnabled && ( + + )} +
+
+ + + + {t(AdminKeys.UsersEdit.LoginInfoTitle)} + + +
+
+ {t(AdminKeys.UsersEdit.FailedLoginAttempts)} + {user.accessFailedCount} +
+
+ {t(AdminKeys.UsersEdit.LastLogin)} + + {user.lastLoginAt + ? new Date(user.lastLoginAt).toLocaleString() + : t(AdminKeys.UsersEdit.LastLoginNever)} + +
+
+ {t(AdminKeys.UsersEdit.CreatedAt)} + {new Date(user.createdAt).toLocaleString()} +
+
+
+
+ + ); +} diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSessionsTab.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSessionsTab.tsx new file mode 100644 index 00000000..132714a5 --- /dev/null +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSessionsTab.tsx @@ -0,0 +1,104 @@ +import { router } from '@inertiajs/react'; +import { useTranslation } from '@simplemodule/client/use-translation'; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; +import { AdminKeys } from '@/Locales/keys'; + +interface Session { + tokenId: string; + type: string; + applicationName: string | null; + creationDate: string | null; + expirationDate: string | null; +} + +interface Props { + userId: string; + activeSessions: Session[]; + onRevokeAll: () => void; +} + +export function UserSessionsTab({ userId, activeSessions, onRevokeAll }: Props) { + const { t } = useTranslation('Admin'); + + return ( + + +
+ {t(AdminKeys.UsersEdit.ActiveSessionsTitle)} + {activeSessions.length > 0 && ( + + )} +
+
+ + {activeSessions.length === 0 ? ( +

{t(AdminKeys.UsersEdit.NoActiveSessions)}

+ ) : ( +
+ + + + {t(AdminKeys.UsersEdit.ColType)} + {t(AdminKeys.UsersEdit.ColApplication)} + {t(AdminKeys.UsersEdit.ColCreated)} + {t(AdminKeys.UsersEdit.ColExpires)} + + + + + {activeSessions.map((session) => ( + + + + {session.type === 'refresh_token' + ? t(AdminKeys.UsersEdit.SessionTypeRefresh) + : t(AdminKeys.UsersEdit.SessionTypeAccess)} + + + {session.applicationName || '\u2014'} + + {session.creationDate + ? new Date(session.creationDate).toLocaleString() + : '\u2014'} + + + {session.expirationDate + ? new Date(session.expirationDate).toLocaleString() + : t(AdminKeys.UsersEdit.SessionExpiresNever)} + + + + + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.Dashboard.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.Dashboard.cs new file mode 100644 index 00000000..7f424eca --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.Dashboard.cs @@ -0,0 +1,150 @@ +using SimpleModule.AuditLogs.Contracts; + +namespace SimpleModule.AuditLogs; + +public sealed partial class AuditLogService +{ + public async Task GetStatsAsync(DateTimeOffset from, DateTimeOffset to) + { + var dashboard = await GetDashboardStatsAsync(from, to); + return new AuditStats + { + TotalEntries = dashboard.TotalEntries, + UniqueUsers = dashboard.UniqueUsers, + ByModule = dashboard.ByModule, + ByAction = dashboard.ByAction, + ByStatusCode = dashboard.ByStatusCategory, + }; + } + + public async Task GetDashboardStatsAsync( + DateTimeOffset from, + DateTimeOffset to, + string? userId = null + ) + { + var entries = await QueryByTimeRangeAsync(from, to, userId); + + var totalEntries = entries.Count; + var uniqueUsers = entries + .Where(e => e.UserId is not null) + .Select(e => e.UserId) + .Distinct() + .Count(); + + long durationSum = 0; + int durationCount = 0; + int statusTotal = 0; + int statusErrors = 0; + foreach (var e in entries) + { + if (e.DurationMs.HasValue) + { + durationSum += e.DurationMs.Value; + durationCount++; + } + if (e.StatusCode.HasValue) + { + statusTotal++; + if (e.StatusCode.Value >= 400) + statusErrors++; + } + } + var averageDuration = durationCount > 0 ? (double)durationSum / durationCount : 0; + var errorRate = statusTotal > 0 ? (double)statusErrors / statusTotal * 100 : 0; + + var bySource = entries + .GroupBy(e => e.Source) + .ToDictionary(g => g.Key.ToString(), g => g.Count()); + + var byAction = entries + .Where(e => e.Action.HasValue) + .GroupBy(e => e.Action!.Value) + .ToDictionary(g => g.Key.ToString(), g => g.Count()); + + var byModule = entries + .Where(e => e.Module is not null) + .GroupBy(e => e.Module!) + .ToDictionary(g => g.Key, g => g.Count()); + + var byStatusCategory = entries + .Where(e => e.StatusCode.HasValue) + .GroupBy(e => + e.StatusCode!.Value switch + { + >= 200 and < 300 => "2xx", + >= 300 and < 400 => "3xx", + >= 400 and < 500 => "4xx", + >= 500 => "5xx", + _ => "Other", + } + ) + .ToDictionary(g => g.Key, g => g.Count()); + + var byEntityType = entries + .Where(e => e.EntityType is not null) + .GroupBy(e => e.EntityType!) + .OrderByDescending(g => g.Count()) + .Take(10) + .ToDictionary(g => g.Key, g => g.Count()); + + var topUsers = entries + .Where(e => e.UserId is not null) + .GroupBy(e => e.UserName ?? e.UserId!) + .OrderByDescending(g => g.Count()) + .Take(10) + .Select(g => new NamedCount { Name = g.Key, Count = g.Count() }) + .ToList(); + + var topPaths = entries + .Where(e => e.Path is not null) + .GroupBy(e => e.Path!) + .OrderByDescending(g => g.Count()) + .Take(10) + .Select(g => new NamedCount { Name = g.Key, Count = g.Count() }) + .ToList(); + + var timeline = entries + .GroupBy(e => e.Timestamp.Date) + .OrderBy(g => g.Key) + .Select(g => new TimelinePoint + { + Date = g.Key.ToString( + "yyyy-MM-dd", + System.Globalization.CultureInfo.InvariantCulture + ), + Http = g.Count(e => e.Source == AuditSource.Http), + Domain = g.Count(e => e.Source == AuditSource.Domain), + Changes = g.Count(e => e.Source == AuditSource.ChangeTracker), + }) + .ToList(); + + var hourlyDistribution = entries + .GroupBy(e => e.Timestamp.Hour) + .OrderBy(g => g.Key) + .Select(g => new NamedCount + { + Name = + g.Key.ToString("D2", System.Globalization.CultureInfo.InvariantCulture) + ":00", + Count = g.Count(), + }) + .ToList(); + + return new DashboardStats + { + TotalEntries = totalEntries, + UniqueUsers = uniqueUsers, + AverageDurationMs = Math.Round(averageDuration, 1), + ErrorRate = Math.Round(errorRate, 1), + BySource = bySource, + ByAction = byAction, + ByModule = byModule, + ByStatusCategory = byStatusCategory, + ByEntityType = byEntityType, + TopUsers = topUsers, + TopPaths = topPaths, + Timeline = timeline, + HourlyDistribution = hourlyDistribution, + }; + } +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.Export.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.Export.cs new file mode 100644 index 00000000..c9272b7b --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.Export.cs @@ -0,0 +1,71 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SimpleModule.AuditLogs.Contracts; + +namespace SimpleModule.AuditLogs; + +public sealed partial class AuditLogService +{ + private static readonly JsonSerializerOptions s_exportJsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task ExportAsync(AuditExportRequest request) + { + var query = BuildQuery(request); + var entries = await query.OrderByDescending(e => e.Id).AsNoTracking().ToListAsync(); + + return request.EffectiveFormat.Equals("json", StringComparison.OrdinalIgnoreCase) + ? ExportAsJson(entries) + : ExportAsCsv(entries); + } + + private static MemoryStream ExportAsCsv(List entries) + { + var sb = new StringBuilder(); + sb.AppendLine( + "Timestamp,Source,UserId,UserName,HttpMethod,Path,StatusCode,DurationMs,Module,EntityType,EntityId,Action,Changes" + ); + foreach (var e in entries) + { + sb.Append(CultureInfo.InvariantCulture, $"{e.Timestamp:O},"); + sb.Append(CultureInfo.InvariantCulture, $"{e.Source},"); + sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.UserId)},"); + sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.UserName)},"); + sb.Append(CultureInfo.InvariantCulture, $"{e.HttpMethod},"); + sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.Path)},"); + sb.Append(CultureInfo.InvariantCulture, $"{e.StatusCode},"); + sb.Append(CultureInfo.InvariantCulture, $"{e.DurationMs},"); + sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.Module)},"); + sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.EntityType)},"); + sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.EntityId)},"); + sb.Append(CultureInfo.InvariantCulture, $"{e.Action},"); + sb.AppendLine(CsvEscape(e.Changes)); + } + return new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())); + } + + private static MemoryStream ExportAsJson(List entries) + { + var json = JsonSerializer.SerializeToUtf8Bytes(entries, s_exportJsonOptions); + return new MemoryStream(json); + } + + private static string CsvEscape(string? value) + { + if (string.IsNullOrEmpty(value)) + return ""; + if ( + value.Contains('"', StringComparison.Ordinal) + || value.Contains(',', StringComparison.Ordinal) + || value.Contains('\n', StringComparison.Ordinal) + || value.Contains('\r', StringComparison.Ordinal) + ) + return $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + return value; + } +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.cs index 57e5a1a1..eb39a65c 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.cs @@ -1,6 +1,3 @@ -using System.Globalization; -using System.Text; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,12 +13,6 @@ public sealed partial class AuditLogService( ILogger logger ) : IAuditLogContracts { - private static readonly JsonSerializerOptions s_exportJsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - public async Task> QueryAsync(AuditQueryRequest request) { var query = BuildQuery(request); @@ -79,29 +70,6 @@ await db .AsNoTracking() .ToListAsync(); - public async Task ExportAsync(AuditExportRequest request) - { - var query = BuildQuery(request); - var entries = await query.OrderByDescending(e => e.Id).AsNoTracking().ToListAsync(); - - return request.EffectiveFormat.Equals("json", StringComparison.OrdinalIgnoreCase) - ? ExportAsJson(entries) - : ExportAsCsv(entries); - } - - public async Task GetStatsAsync(DateTimeOffset from, DateTimeOffset to) - { - var dashboard = await GetDashboardStatsAsync(from, to); - return new AuditStats - { - TotalEntries = dashboard.TotalEntries, - UniqueUsers = dashboard.UniqueUsers, - ByModule = dashboard.ByModule, - ByAction = dashboard.ByAction, - ByStatusCode = dashboard.ByStatusCategory, - }; - } - public async Task WriteBatchAsync(IReadOnlyList entries) { db.AuditEntries.AddRange(entries); @@ -109,137 +77,6 @@ public async Task WriteBatchAsync(IReadOnlyList entries) LogBatchWritten(logger, entries.Count); } - public async Task GetDashboardStatsAsync( - DateTimeOffset from, - DateTimeOffset to, - string? userId = null - ) - { - var entries = await QueryByTimeRangeAsync(from, to, userId); - - var totalEntries = entries.Count; - var uniqueUsers = entries - .Where(e => e.UserId is not null) - .Select(e => e.UserId) - .Distinct() - .Count(); - - long durationSum = 0; - int durationCount = 0; - int statusTotal = 0; - int statusErrors = 0; - foreach (var e in entries) - { - if (e.DurationMs.HasValue) - { - durationSum += e.DurationMs.Value; - durationCount++; - } - if (e.StatusCode.HasValue) - { - statusTotal++; - if (e.StatusCode.Value >= 400) - statusErrors++; - } - } - var averageDuration = durationCount > 0 ? (double)durationSum / durationCount : 0; - var errorRate = statusTotal > 0 ? (double)statusErrors / statusTotal * 100 : 0; - - var bySource = entries - .GroupBy(e => e.Source) - .ToDictionary(g => g.Key.ToString(), g => g.Count()); - - var byAction = entries - .Where(e => e.Action.HasValue) - .GroupBy(e => e.Action!.Value) - .ToDictionary(g => g.Key.ToString(), g => g.Count()); - - var byModule = entries - .Where(e => e.Module is not null) - .GroupBy(e => e.Module!) - .ToDictionary(g => g.Key, g => g.Count()); - - var byStatusCategory = entries - .Where(e => e.StatusCode.HasValue) - .GroupBy(e => - e.StatusCode!.Value switch - { - >= 200 and < 300 => "2xx", - >= 300 and < 400 => "3xx", - >= 400 and < 500 => "4xx", - >= 500 => "5xx", - _ => "Other", - } - ) - .ToDictionary(g => g.Key, g => g.Count()); - - var byEntityType = entries - .Where(e => e.EntityType is not null) - .GroupBy(e => e.EntityType!) - .OrderByDescending(g => g.Count()) - .Take(10) - .ToDictionary(g => g.Key, g => g.Count()); - - var topUsers = entries - .Where(e => e.UserId is not null) - .GroupBy(e => e.UserName ?? e.UserId!) - .OrderByDescending(g => g.Count()) - .Take(10) - .Select(g => new NamedCount { Name = g.Key, Count = g.Count() }) - .ToList(); - - var topPaths = entries - .Where(e => e.Path is not null) - .GroupBy(e => e.Path!) - .OrderByDescending(g => g.Count()) - .Take(10) - .Select(g => new NamedCount { Name = g.Key, Count = g.Count() }) - .ToList(); - - var timeline = entries - .GroupBy(e => e.Timestamp.Date) - .OrderBy(g => g.Key) - .Select(g => new TimelinePoint - { - Date = g.Key.ToString( - "yyyy-MM-dd", - System.Globalization.CultureInfo.InvariantCulture - ), - Http = g.Count(e => e.Source == AuditSource.Http), - Domain = g.Count(e => e.Source == AuditSource.Domain), - Changes = g.Count(e => e.Source == AuditSource.ChangeTracker), - }) - .ToList(); - - var hourlyDistribution = entries - .GroupBy(e => e.Timestamp.Hour) - .OrderBy(g => g.Key) - .Select(g => new NamedCount - { - Name = - g.Key.ToString("D2", System.Globalization.CultureInfo.InvariantCulture) + ":00", - Count = g.Count(), - }) - .ToList(); - - return new DashboardStats - { - TotalEntries = totalEntries, - UniqueUsers = uniqueUsers, - AverageDurationMs = Math.Round(averageDuration, 1), - ErrorRate = Math.Round(errorRate, 1), - BySource = bySource, - ByAction = byAction, - ByModule = byModule, - ByStatusCategory = byStatusCategory, - ByEntityType = byEntityType, - TopUsers = topUsers, - TopPaths = topPaths, - Timeline = timeline, - HourlyDistribution = hourlyDistribution, - }; - } - public async Task PurgeOlderThanAsync(DateTimeOffset cutoff) { var count = await db.AuditEntries.Where(e => e.Timestamp < cutoff).ExecuteDeleteAsync(); @@ -329,51 +166,6 @@ private IQueryable BuildQuery(AuditQueryRequest request) return query; } - private static MemoryStream ExportAsCsv(List entries) - { - var sb = new StringBuilder(); - sb.AppendLine( - "Timestamp,Source,UserId,UserName,HttpMethod,Path,StatusCode,DurationMs,Module,EntityType,EntityId,Action,Changes" - ); - foreach (var e in entries) - { - sb.Append(CultureInfo.InvariantCulture, $"{e.Timestamp:O},"); - sb.Append(CultureInfo.InvariantCulture, $"{e.Source},"); - sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.UserId)},"); - sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.UserName)},"); - sb.Append(CultureInfo.InvariantCulture, $"{e.HttpMethod},"); - sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.Path)},"); - sb.Append(CultureInfo.InvariantCulture, $"{e.StatusCode},"); - sb.Append(CultureInfo.InvariantCulture, $"{e.DurationMs},"); - sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.Module)},"); - sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.EntityType)},"); - sb.Append(CultureInfo.InvariantCulture, $"{CsvEscape(e.EntityId)},"); - sb.Append(CultureInfo.InvariantCulture, $"{e.Action},"); - sb.AppendLine(CsvEscape(e.Changes)); - } - return new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())); - } - - private static MemoryStream ExportAsJson(List entries) - { - var json = JsonSerializer.SerializeToUtf8Bytes(entries, s_exportJsonOptions); - return new MemoryStream(json); - } - - private static string CsvEscape(string? value) - { - if (string.IsNullOrEmpty(value)) - return ""; - if ( - value.Contains('"', StringComparison.Ordinal) - || value.Contains(',', StringComparison.Ordinal) - || value.Contains('\n', StringComparison.Ordinal) - || value.Contains('\r', StringComparison.Ordinal) - ) - return $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; - return value; - } - [LoggerMessage(Level = LogLevel.Debug, Message = "Wrote batch of {Count} audit entries")] private static partial void LogBatchWritten(ILogger logger, int count); diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx index 3162f271..159c1536 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx @@ -1,40 +1,12 @@ import { router } from '@inertiajs/react'; import { useTranslation } from '@simplemodule/client/use-translation'; -import { - Badge, - Button, - Card, - CardContent, - Input, - PageShell, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@simplemodule/ui'; +import { Button, Card, CardContent, PageShell, TooltipProvider } from '@simplemodule/ui'; import { type FormEvent, useState } from 'react'; import { AuditLogsKeys } from '@/Locales/keys'; import type { AuditEntry, AuditQueryRequest } from '@/types'; -import { - ACTION_LABELS, - actionBadgeVariant, - formatTimestamp, - relativeTime, - SOURCE_LABELS, - sourceBadgeVariant, - statusBadgeVariant, -} from '@/utils/audit-utils'; +import { BrowseFilters } from './components/BrowseFilters'; +import { BrowsePagination } from './components/BrowsePagination'; +import { BrowseResultsTable } from './components/BrowseResultsTable'; interface PagedResult { items: T[]; @@ -48,13 +20,6 @@ interface Props { filters: AuditQueryRequest; } -const DATE_PRESETS = [ - { label: 'Last hour', hours: 1 }, - { label: 'Last 24h', hours: 24 }, - { label: 'Last 7 days', hours: 168 }, - { label: 'Last 30 days', hours: 720 }, -]; - function buildFilterParams(f: Partial, page?: number): Record { const params: Record = {}; if (f.from) params.from = String(f.from); @@ -68,36 +33,6 @@ function buildFilterParams(f: Partial, page?: number): Record return params; } -function ChevronLeft() { - return ( - - ); -} - -function ChevronRight() { - return ( - - ); -} - export default function Browse({ result, filters }: Props) { const { t } = useTranslation('AuditLogs'); const [from, setFrom] = useState(filters.from ? String(filters.from) : ''); @@ -159,8 +94,9 @@ export default function Browse({ result, filters }: Props) { window.location.href = `/api/audit-logs/export?${query}`; } - const hasActiveFilters = - from || to || source !== '__all__' || action !== '__all__' || module || searchText; + const hasActiveFilters = Boolean( + from || to || source !== '__all__' || action !== '__all__' || module || searchText, + ); const startItem = (currentPage - 1) * result.pageSize + 1; const endItem = Math.min(currentPage * result.pageSize, result.totalCount); @@ -184,126 +120,25 @@ export default function Browse({ result, filters }: Props) { } > - {/* Filter Panel */} - - - {/* Quick date presets */} -
- - {t(AuditLogsKeys.Browse.QuickRange)} - - {DATE_PRESETS.map((preset) => ( - - ))} -
- -
-
-
- - setFrom(e.target.value)} - /> -
-
- - setTo(e.target.value)} - /> -
-
- - {t(AuditLogsKeys.Browse.FilterSource)} - - -
-
- - {t(AuditLogsKeys.Browse.FilterAction)} - - -
-
- - setModule(e.target.value)} - /> -
-
- - setSearchText(e.target.value)} - /> -
-
- - {hasActiveFilters && ( - - )} -
-
-
-
-
+ - {/* Results Table */} {result.items.length === 0 ? ( @@ -324,93 +159,12 @@ export default function Browse({ result, filters }: Props) {
- - - - {t(AuditLogsKeys.Browse.ColTime)} - {t(AuditLogsKeys.Browse.ColSource)} - {t(AuditLogsKeys.Browse.ColUser)} - {t(AuditLogsKeys.Browse.ColAction)} - {t(AuditLogsKeys.Browse.ColModule)} - {t(AuditLogsKeys.Browse.ColPath)} - {t(AuditLogsKeys.Browse.ColStatus)} - {t(AuditLogsKeys.Browse.ColDuration)} - - - - {result.items.map((entry) => ( - router.get(`/audit-logs/${entry.id}`)} - > - - - - - {relativeTime(entry.timestamp)} - - - {formatTimestamp(entry.timestamp)} - - - - - {SOURCE_LABELS[entry.source] ?? 'Unknown'} - - - - {entry.userName || entry.userId || '\u2014'} - - - {entry.action != null ? ( - - {ACTION_LABELS[entry.action] ?? 'Unknown'} - - ) : ( - '\u2014' - )} - - {entry.module || '\u2014'} - - {entry.path ? ( - - - {entry.path} - - - - {entry.httpMethod && `${entry.httpMethod} `} - {entry.path} - - - - ) : ( - '\u2014' - )} - - - {entry.statusCode != null ? ( - - {entry.statusCode} - - ) : ( - '\u2014' - )} - - - {entry.durationMs != null ? `${entry.durationMs}ms` : '\u2014'} - - - ))} - -
+
)} - {/* Server-side Pagination */} {result.totalCount > 0 && (
@@ -420,65 +174,14 @@ export default function Browse({ result, filters }: Props) { total: result.totalCount.toLocaleString(), })} - {totalPages > 1 && ( -
- - {paginationRange(currentPage, totalPages).map((p) => - p === 'ellipsis-start' || p === 'ellipsis-end' ? ( - - ... - - ) : ( - - ), - )} - -
- )} +
)} ); } - -/** Build a compact pagination range: 1 ... 4 5 [6] 7 8 ... 20 */ -function paginationRange( - current: number, - total: number, -): (number | 'ellipsis-start' | 'ellipsis-end')[] { - if (total <= 7) { - return Array.from({ length: total }, (_, i) => i + 1); - } - const pages: (number | 'ellipsis-start' | 'ellipsis-end')[] = []; - pages.push(1); - if (current > 3) pages.push('ellipsis-start'); - const start = Math.max(2, current - 1); - const end = Math.min(total - 1, current + 1); - for (let i = start; i <= end; i++) pages.push(i); - if (current < total - 2) pages.push('ellipsis-end'); - pages.push(total); - return pages; -} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Dashboard.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Dashboard.tsx index f9b82061..4696cf8d 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Dashboard.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Dashboard.tsx @@ -1,38 +1,24 @@ import { router } from '@inertiajs/react'; import { useTranslation } from '@simplemodule/client/use-translation'; -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, - DatePicker, - PageShell, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@simplemodule/ui'; +import { type ChartConfig, PageShell } from '@simplemodule/ui'; import { useState } from 'react'; -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Cell, - Pie, - PieChart, - XAxis, - YAxis, -} from 'recharts'; import { AuditLogsKeys } from '@/Locales/keys'; import type { DashboardStats, NamedCount } from '@/types'; +import { DonutCard, HBarCard } from './components/DashboardCharts'; +import { DashboardFilters } from './components/DashboardFilters'; +import { + dictToChartData, + formatDate, + PALETTE, + SOURCE_COLORS, + STATUS_COLORS, +} from './components/dashboard-constants'; +import { + EntityTypesChart, + HourlyDistributionChart, + TimelineAreaChart, +} from './components/InlineCharts'; +import { KpiCard } from './components/KpiCard'; interface Props { stats: DashboardStats; @@ -42,197 +28,6 @@ interface Props { users: NamedCount[]; } -// ---- Chart color palette ---- - -const PALETTE = [ - 'var(--color-primary)', - 'var(--color-info)', - 'var(--color-warning)', - 'var(--color-danger)', - 'var(--color-success-light)', - 'var(--color-accent)', - 'var(--color-muted)', - 'var(--color-primary-light)', -]; - -const SOURCE_COLORS: Record = { - Http: 'var(--color-info)', - Domain: 'var(--color-primary)', - ChangeTracker: 'var(--color-warning)', -}; - -const STATUS_COLORS: Record = { - '2xx': 'var(--color-success)', - '3xx': 'var(--color-info)', - '4xx': 'var(--color-warning)', - '5xx': 'var(--color-danger)', - Other: 'var(--color-muted)', -}; - -const DATE_PRESETS = [ - { label: 'Last 24h', hours: 24 }, - { label: 'Last 7 days', hours: 168 }, - { label: 'Last 30 days', hours: 720 }, - { label: 'Last 90 days', hours: 2160 }, -]; - -// ---- Helpers ---- - -function dictToChartData(dict: Record): { name: string; value: number }[] { - return Object.entries(dict) - .map(([name, value]) => ({ name, value })) - .sort((a, b) => b.value - a.value); -} - -function formatDate(iso: string): string { - return iso.slice(0, 10); -} - -// ---- KPI Card ---- - -function KpiCard({ - title, - value, - subtitle, - accent, - onClick, -}: { - title: string; - value: string; - subtitle?: string; - accent?: 'default' | 'danger'; - onClick?: () => void; -}) { - return ( - - -

{title}

-

- {value} -

- {subtitle &&

{subtitle}

} -
-
- ); -} - -// ---- Reusable chart cards ---- - -function DonutCard({ - title, - data, - colors, - config, -}: { - title: string; - data: { name: string; value: number }[]; - colors: Record; - config: ChartConfig; -}) { - return ( - - - {title} - - - - - } /> - - {data.map((d) => ( - - ))} - - - -
- {data.map((d) => ( -
-
- {d.name} - {d.value.toLocaleString()} -
- ))} -
- - - ); -} - -function HBarCard({ - title, - data, - dataKey, - config, - fill, - yAxisWidth = 100, -}: { - title: string; - data: readonly { name: string; count?: number; value?: number }[]; - dataKey: string; - config: ChartConfig; - fill?: string; - yAxisWidth?: number; -}) { - return ( - - - {title} - - - - - - - - } /> - - {!fill && - data.map((d, i) => ( - - ))} - - - - - - ); -} - -// ---- Main Dashboard ---- - export default function Dashboard({ stats, from, to, userId, users }: Props) { const { t } = useTranslation('AuditLogs'); const [dateFrom, setDateFrom] = useState(new Date(from)); @@ -321,61 +116,17 @@ export default function Dashboard({ stats, from, to, userId, users }: Props) { title={t(AuditLogsKeys.Dashboard.Title)} description={t(AuditLogsKeys.Dashboard.Description)} actions={ -
- {/* Quick date presets */} - {DATE_PRESETS.map((preset) => ( - - ))} -
- - {t(AuditLogsKeys.Dashboard.FilterFrom)} - - -
-
- - {t(AuditLogsKeys.Dashboard.FilterTo)} - - -
-
- - {t(AuditLogsKeys.Dashboard.FilterUser)} - - -
- -
+ } > {/* KPI Cards */} @@ -407,67 +158,11 @@ export default function Dashboard({ stats, from, to, userId, users }: Props) { {/* Timeline Area Chart */} {stats.timeline.length > 0 && ( - - - - {t(AuditLogsKeys.Dashboard.ActivityTimeline)} - - - - - - - - - - - - - - - - - - - - - v.slice(5)} - /> - - } /> - - - - - - - + )} {/* Row: Source Pie + Action Bar */} @@ -534,55 +229,20 @@ export default function Dashboard({ stats, from, to, userId, users }: Props) { )}
- {/* Entity Types */} {entityData.length > 0 && ( - - - - {t(AuditLogsKeys.Dashboard.EntityTypes)} - - - - - - - - - } /> - - {entityData.map((d, i) => ( - - ))} - - - - - + )} - {/* Hourly Distribution */} {stats.hourlyDistribution.length > 0 && ( - - - - {t(AuditLogsKeys.Dashboard.HourlyDistribution)} - - - - - - - - - } /> - - - - - + )} ); diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx index f5ee2057..046ff351 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx @@ -1,164 +1,30 @@ import { router } from '@inertiajs/react'; import { useTranslation } from '@simplemodule/client/use-translation'; import { - Badge, Button, Card, CardContent, CardHeader, CardTitle, PageShell, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Tooltip, - TooltipContent, TooltipProvider, - TooltipTrigger, } from '@simplemodule/ui'; -import { useEffect, useState } from 'react'; import { AuditLogsKeys } from '@/Locales/keys'; import type { AuditEntry } from '@/types'; +import { CorrelatedTable } from './components/CorrelatedTable'; import { - ACTION_LABELS, - actionBadgeVariant, - formatTimestamp, - relativeTime, - SOURCE_LABELS, - sourceBadgeVariant, - statusBadgeVariant, -} from '@/utils/audit-utils'; + ChangesCard, + DomainDetailsCard, + HttpDetailsCard, + OverviewCard, +} from './components/DetailCards'; +import { formatJson, parseChanges } from './components/DetailShared'; interface Props { entry: AuditEntry; correlated: AuditEntry[]; } -function LabeledField({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
-
{label}
-
{children || '\u2014'}
-
- ); -} - -function formatJson(raw: string): string { - try { - return JSON.stringify(JSON.parse(raw), null, 2); - } catch { - return raw; - } -} - -interface ChangeEntry { - field: string; - old?: unknown; - new?: unknown; - value?: unknown; -} - -function parseChanges(raw: string): ChangeEntry[] { - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return (parsed as Array>).map((item) => { - if ('old' in item || 'new' in item) { - return { field: String(item.field ?? ''), old: item.old, new: item.new }; - } - if ('oldValue' in item || 'newValue' in item) { - return { field: String(item.field ?? ''), old: item.oldValue, new: item.newValue }; - } - return { field: String(item.field ?? ''), value: item.value }; - }); - } - if (typeof parsed === 'object' && parsed !== null) { - return Object.entries(parsed).map(([field, val]) => { - if ( - typeof val === 'object' && - val !== null && - ('old' in val || 'new' in val || 'oldValue' in val || 'newValue' in val) - ) { - const v = val as { old?: unknown; new?: unknown; oldValue?: unknown; newValue?: unknown }; - return { field, old: v.old ?? v.oldValue, new: v.new ?? v.newValue }; - } - return { field, value: val }; - }); - } - return []; - } catch { - return []; - } -} - -function hasUpdateStyle(changes: ChangeEntry[]): boolean { - return changes.some((c) => 'old' in c || 'new' in c); -} - -function CopyButton({ - text, - labelCopy, - labelCopied, -}: { - text: string; - labelCopy: string; - labelCopied: string; -}) { - const [copied, setCopied] = useState(false); - - useEffect(() => { - if (!copied) return; - const id = setTimeout(() => setCopied(false), 2000); - return () => clearTimeout(id); - }, [copied]); - - function handleCopy() { - navigator.clipboard.writeText(text).catch(() => {}); - setCopied(true); - } - - return ( - - - - - {copied ? labelCopied : labelCopy} - - ); -} - export default function Detail({ entry, correlated }: Props) { const { t } = useTranslation('AuditLogs'); const showHttp = !!entry.httpMethod; @@ -167,7 +33,6 @@ export default function Detail({ entry, correlated }: Props) { const showMetadata = !!entry.metadata; const changes = showChanges ? parseChanges(entry.changes) : []; - const isUpdate = hasUpdateStyle(changes); return ( @@ -184,184 +49,14 @@ export default function Detail({ entry, correlated }: Props) { { label: t(AuditLogsKeys.Detail.BreadcrumbEntry, { id: String(entry.id) }) }, ]} > - {/* Overview Card */} - - - {t(AuditLogsKeys.Detail.OverviewTitle)} - - -
- - - - {relativeTime(entry.timestamp)} - - {formatTimestamp(entry.timestamp)} - - - - - {SOURCE_LABELS[entry.source] ?? `Unknown (${entry.source})`} - - - - - {entry.correlationId} - - - - - {entry.userName || entry.userId} - - - {entry.ipAddress} - - - {entry.userAgent} - -
-
-
+ - {/* HTTP Details Card */} - {showHttp && ( - - - {t(AuditLogsKeys.Detail.HttpDetailsTitle)} - - -
- - - {entry.httpMethod} - - {entry.path} - - - {entry.queryString ? ( - {entry.queryString} - ) : ( - '\u2014' - )} - - - {entry.statusCode} - - - {entry.durationMs != null ? `${entry.durationMs}ms` : '\u2014'} - -
- {entry.requestBody && ( -
-

- {t(AuditLogsKeys.Detail.RequestBody)} -

-
-                    {formatJson(entry.requestBody)}
-                  
-
- )} -
-
- )} + {showHttp && } - {/* Domain Details Card */} - {showDomain && ( - - - {t(AuditLogsKeys.Detail.DomainDetailsTitle)} - - -
- - {entry.module} - - - {entry.entityType} - - - {entry.entityId} - - - {entry.action != null ? ( - - {ACTION_LABELS[entry.action] ?? `Unknown (${entry.action})`} - - ) : ( - '\u2014' - )} - -
-
-
- )} + {showDomain && } - {/* Changes Card */} - {showChanges && changes.length > 0 && ( - - - {t(AuditLogsKeys.Detail.ChangesTitle)} - - -
- - - - {t(AuditLogsKeys.Detail.ColField)} - {isUpdate ? ( - <> - {t(AuditLogsKeys.Detail.ColOldValue)} - {t(AuditLogsKeys.Detail.ColNewValue)} - - ) : ( - {t(AuditLogsKeys.Detail.ColValue)} - )} - - - - {changes.map((change) => ( - - {change.field} - {isUpdate ? ( - <> - - {change.old != null ? ( - - {String(change.old)} - - ) : ( - \u2014 - )} - - - {change.new != null ? ( - - {String(change.new)} - - ) : ( - \u2014 - )} - - - ) : ( - - {change.value != null ? String(change.value) : '\u2014'} - - )} - - ))} - -
-
-
-
- )} + {showChanges && changes.length > 0 && } - {/* Metadata Card */} {showMetadata && ( @@ -375,69 +70,8 @@ export default function Detail({ entry, correlated }: Props) { )} - {/* Correlated Entries Card */} {correlated.length > 0 && ( - - - - {t(AuditLogsKeys.Detail.CorrelatedTitle)} - - {t(AuditLogsKeys.Detail.CorrelatedRelated, { count: String(correlated.length) })} - - - - -
- - - - {t(AuditLogsKeys.Detail.ColId)} - {t(AuditLogsKeys.Detail.ColTime)} - {t(AuditLogsKeys.Detail.ColSource)} - {t(AuditLogsKeys.Detail.ColAction)} - {t(AuditLogsKeys.Detail.ColModule)} - {t(AuditLogsKeys.Detail.ColPath)} - - - - {correlated.map((e) => ( - router.get(`/audit-logs/${e.id}`)} - > - #{e.id} - - - - {relativeTime(e.timestamp)} - - {formatTimestamp(e.timestamp)} - - - - - {SOURCE_LABELS[e.source] ?? 'Unknown'} - - - - {e.action != null ? ( - - {ACTION_LABELS[e.action] ?? `Unknown (${e.action})`} - - ) : ( - '\u2014' - )} - - {e.module || '\u2014'} - {e.path || '\u2014'} - - ))} - -
-
-
-
+ )}
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/BrowseFilters.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/BrowseFilters.tsx new file mode 100644 index 00000000..b7fdcf98 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/BrowseFilters.tsx @@ -0,0 +1,176 @@ +import { useTranslation } from '@simplemodule/client/use-translation'; +import { + Button, + Card, + CardContent, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@simplemodule/ui'; +import type { FormEvent } from 'react'; +import { AuditLogsKeys } from '@/Locales/keys'; +import { ACTION_LABELS, SOURCE_LABELS } from '@/utils/audit-utils'; + +const DATE_PRESETS = [ + { label: 'Last hour', hours: 1 }, + { label: 'Last 24h', hours: 24 }, + { label: 'Last 7 days', hours: 168 }, + { label: 'Last 30 days', hours: 720 }, +]; + +interface Props { + from: string; + to: string; + source: string; + action: string; + module: string; + searchText: string; + hasActiveFilters: boolean; + onFromChange: (v: string) => void; + onToChange: (v: string) => void; + onSourceChange: (v: string) => void; + onActionChange: (v: string) => void; + onModuleChange: (v: string) => void; + onSearchTextChange: (v: string) => void; + onApplyFilters: (e?: FormEvent) => void; + onClearFilters: () => void; + onApplyDatePreset: (hours: number) => void; +} + +export function BrowseFilters({ + from, + to, + source, + action, + module, + searchText, + hasActiveFilters, + onFromChange, + onToChange, + onSourceChange, + onActionChange, + onModuleChange, + onSearchTextChange, + onApplyFilters, + onClearFilters, + onApplyDatePreset, +}: Props) { + const { t } = useTranslation('AuditLogs'); + return ( + + +
+ + {t(AuditLogsKeys.Browse.QuickRange)} + + {DATE_PRESETS.map((preset) => ( + + ))} +
+ +
+
+
+ + onFromChange(e.target.value)} + /> +
+
+ + onToChange(e.target.value)} + /> +
+
+ + {t(AuditLogsKeys.Browse.FilterSource)} + + +
+
+ + {t(AuditLogsKeys.Browse.FilterAction)} + + +
+
+ + onModuleChange(e.target.value)} + /> +
+
+ + onSearchTextChange(e.target.value)} + /> +
+
+ + {hasActiveFilters && ( + + )} +
+
+
+
+
+ ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/BrowsePagination.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/BrowsePagination.tsx new file mode 100644 index 00000000..106f6028 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/BrowsePagination.tsx @@ -0,0 +1,98 @@ +import { Button } from '@simplemodule/ui'; + +/** Build a compact pagination range: 1 ... 4 5 [6] 7 8 ... 20 */ +function paginationRange( + current: number, + total: number, +): (number | 'ellipsis-start' | 'ellipsis-end')[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1); + } + const pages: (number | 'ellipsis-start' | 'ellipsis-end')[] = []; + pages.push(1); + if (current > 3) pages.push('ellipsis-start'); + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + for (let i = start; i <= end; i++) pages.push(i); + if (current < total - 2) pages.push('ellipsis-end'); + pages.push(total); + return pages; +} + +function ChevronLeft() { + return ( + + ); +} + +function ChevronRight() { + return ( + + ); +} + +interface Props { + currentPage: number; + totalPages: number; + onGoToPage: (page: number) => void; +} + +export function BrowsePagination({ currentPage, totalPages, onGoToPage }: Props) { + if (totalPages <= 1) return null; + + return ( +
+ + {paginationRange(currentPage, totalPages).map((p) => + p === 'ellipsis-start' || p === 'ellipsis-end' ? ( + + ... + + ) : ( + + ), + )} + +
+ ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/BrowseResultsTable.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/BrowseResultsTable.tsx new file mode 100644 index 00000000..604cb875 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/BrowseResultsTable.tsx @@ -0,0 +1,107 @@ +import { router } from '@inertiajs/react'; +import { useTranslation } from '@simplemodule/client/use-translation'; +import { + Badge, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@simplemodule/ui'; +import { AuditLogsKeys } from '@/Locales/keys'; +import type { AuditEntry } from '@/types'; +import { + ACTION_LABELS, + actionBadgeVariant, + formatTimestamp, + relativeTime, + SOURCE_LABELS, + sourceBadgeVariant, + statusBadgeVariant, +} from '@/utils/audit-utils'; + +export function BrowseResultsTable({ items }: { items: AuditEntry[] }) { + const { t } = useTranslation('AuditLogs'); + + return ( + + + + {t(AuditLogsKeys.Browse.ColTime)} + {t(AuditLogsKeys.Browse.ColSource)} + {t(AuditLogsKeys.Browse.ColUser)} + {t(AuditLogsKeys.Browse.ColAction)} + {t(AuditLogsKeys.Browse.ColModule)} + {t(AuditLogsKeys.Browse.ColPath)} + {t(AuditLogsKeys.Browse.ColStatus)} + {t(AuditLogsKeys.Browse.ColDuration)} + + + + {items.map((entry) => ( + router.get(`/audit-logs/${entry.id}`)} + > + + + + {relativeTime(entry.timestamp)} + + {formatTimestamp(entry.timestamp)} + + + + + {SOURCE_LABELS[entry.source] ?? 'Unknown'} + + + {entry.userName || entry.userId || '\u2014'} + + {entry.action != null ? ( + + {ACTION_LABELS[entry.action] ?? 'Unknown'} + + ) : ( + '\u2014' + )} + + {entry.module || '\u2014'} + + {entry.path ? ( + + + {entry.path} + + + + {entry.httpMethod && `${entry.httpMethod} `} + {entry.path} + + + + ) : ( + '\u2014' + )} + + + {entry.statusCode != null ? ( + {entry.statusCode} + ) : ( + '\u2014' + )} + + + {entry.durationMs != null ? `${entry.durationMs}ms` : '\u2014'} + + + ))} + +
+ ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/CorrelatedTable.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/CorrelatedTable.tsx new file mode 100644 index 00000000..4487185b --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/CorrelatedTable.tsx @@ -0,0 +1,100 @@ +import { router } from '@inertiajs/react'; +import { useTranslation } from '@simplemodule/client/use-translation'; +import { + Badge, + Card, + CardContent, + CardHeader, + CardTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@simplemodule/ui'; +import { AuditLogsKeys } from '@/Locales/keys'; +import type { AuditEntry } from '@/types'; +import { + ACTION_LABELS, + actionBadgeVariant, + formatTimestamp, + relativeTime, + SOURCE_LABELS, + sourceBadgeVariant, +} from '@/utils/audit-utils'; + +interface Props { + correlated: AuditEntry[]; + currentEntryId: number; +} + +export function CorrelatedTable({ correlated, currentEntryId }: Props) { + const { t } = useTranslation('AuditLogs'); + return ( + + + + {t(AuditLogsKeys.Detail.CorrelatedTitle)} + + {t(AuditLogsKeys.Detail.CorrelatedRelated, { count: String(correlated.length) })} + + + + +
+ + + + {t(AuditLogsKeys.Detail.ColId)} + {t(AuditLogsKeys.Detail.ColTime)} + {t(AuditLogsKeys.Detail.ColSource)} + {t(AuditLogsKeys.Detail.ColAction)} + {t(AuditLogsKeys.Detail.ColModule)} + {t(AuditLogsKeys.Detail.ColPath)} + + + + {correlated.map((e) => ( + router.get(`/audit-logs/${e.id}`)} + > + #{e.id} + + + + {relativeTime(e.timestamp)} + + {formatTimestamp(e.timestamp)} + + + + + {SOURCE_LABELS[e.source] ?? 'Unknown'} + + + + {e.action != null ? ( + + {ACTION_LABELS[e.action] ?? `Unknown (${e.action})`} + + ) : ( + '\u2014' + )} + + {e.module || '\u2014'} + {e.path || '\u2014'} + + ))} + +
+
+
+
+ ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DashboardCharts.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DashboardCharts.tsx new file mode 100644 index 00000000..31a12beb --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DashboardCharts.tsx @@ -0,0 +1,121 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@simplemodule/ui'; +import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts'; +import { PALETTE } from './dashboard-constants'; + +export function DonutCard({ + title, + data, + colors, + config, +}: { + title: string; + data: { name: string; value: number }[]; + colors: Record; + config: ChartConfig; +}) { + return ( + + + {title} + + + + + } /> + + {data.map((d) => ( + + ))} + + + +
+ {data.map((d) => ( +
+
+ {d.name} + {d.value.toLocaleString()} +
+ ))} +
+ + + ); +} + +export function HBarCard({ + title, + data, + dataKey, + config, + fill, + yAxisWidth = 100, +}: { + title: string; + data: readonly { name: string; count?: number; value?: number }[]; + dataKey: string; + config: ChartConfig; + fill?: string; + yAxisWidth?: number; +}) { + return ( + + + {title} + + + + + + + + } /> + + {!fill && + data.map((d, i) => ( + + ))} + + + + + + ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DashboardFilters.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DashboardFilters.tsx new file mode 100644 index 00000000..39fc6a77 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DashboardFilters.tsx @@ -0,0 +1,96 @@ +import { useTranslation } from '@simplemodule/client/use-translation'; +import { + Button, + DatePicker, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@simplemodule/ui'; +import { AuditLogsKeys } from '@/Locales/keys'; +import type { NamedCount } from '@/types'; +import { DATE_PRESETS } from './dashboard-constants'; + +interface Props { + dateFrom: Date | undefined; + dateTo: Date | undefined; + onDateFromChange: (date: Date | undefined) => void; + onDateToChange: (date: Date | undefined) => void; + selectedUser: string; + onSelectedUserChange: (value: string) => void; + users: NamedCount[]; + onApplyFilters: () => void; + onApplyDatePreset: (hours: number) => void; +} + +export function DashboardFilters({ + dateFrom, + dateTo, + onDateFromChange, + onDateToChange, + selectedUser, + onSelectedUserChange, + users, + onApplyFilters, + onApplyDatePreset, +}: Props) { + const { t } = useTranslation('AuditLogs'); + + return ( +
+ {DATE_PRESETS.map((preset) => ( + + ))} +
+ + {t(AuditLogsKeys.Dashboard.FilterFrom)} + + +
+
+ + {t(AuditLogsKeys.Dashboard.FilterTo)} + + +
+
+ + {t(AuditLogsKeys.Dashboard.FilterUser)} + + +
+ +
+ ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DetailCards.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DetailCards.tsx new file mode 100644 index 00000000..1232de1a --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DetailCards.tsx @@ -0,0 +1,215 @@ +import { useTranslation } from '@simplemodule/client/use-translation'; +import { + Badge, + Card, + CardContent, + CardHeader, + CardTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@simplemodule/ui'; +import { AuditLogsKeys } from '@/Locales/keys'; +import type { AuditEntry } from '@/types'; +import { + ACTION_LABELS, + actionBadgeVariant, + formatTimestamp, + relativeTime, + SOURCE_LABELS, + sourceBadgeVariant, + statusBadgeVariant, +} from '@/utils/audit-utils'; +import { + type ChangeEntry, + CopyButton, + formatJson, + hasUpdateStyle, + LabeledField, +} from './DetailShared'; + +export function OverviewCard({ entry }: { entry: AuditEntry }) { + const { t } = useTranslation('AuditLogs'); + return ( + + + {t(AuditLogsKeys.Detail.OverviewTitle)} + + +
+ + + + {relativeTime(entry.timestamp)} + + {formatTimestamp(entry.timestamp)} + + + + + {SOURCE_LABELS[entry.source] ?? `Unknown (${entry.source})`} + + + + + {entry.correlationId} + + + + + {entry.userName || entry.userId} + + + {entry.ipAddress} + + + {entry.userAgent} + +
+
+
+ ); +} + +export function HttpDetailsCard({ entry }: { entry: AuditEntry }) { + const { t } = useTranslation('AuditLogs'); + return ( + + + {t(AuditLogsKeys.Detail.HttpDetailsTitle)} + + +
+ + + {entry.httpMethod} + + {entry.path} + + + {entry.queryString ? ( + {entry.queryString} + ) : ( + '\u2014' + )} + + + {entry.statusCode} + + + {entry.durationMs != null ? `${entry.durationMs}ms` : '\u2014'} + +
+ {entry.requestBody && ( +
+

{t(AuditLogsKeys.Detail.RequestBody)}

+
+              {formatJson(entry.requestBody)}
+            
+
+ )} +
+
+ ); +} + +export function DomainDetailsCard({ entry }: { entry: AuditEntry }) { + const { t } = useTranslation('AuditLogs'); + return ( + + + {t(AuditLogsKeys.Detail.DomainDetailsTitle)} + + +
+ {entry.module} + + {entry.entityType} + + + {entry.entityId} + + + {entry.action != null ? ( + + {ACTION_LABELS[entry.action] ?? `Unknown (${entry.action})`} + + ) : ( + '\u2014' + )} + +
+
+
+ ); +} + +export function ChangesCard({ changes }: { changes: ChangeEntry[] }) { + const { t } = useTranslation('AuditLogs'); + const isUpdate = hasUpdateStyle(changes); + return ( + + + {t(AuditLogsKeys.Detail.ChangesTitle)} + + +
+ + + + {t(AuditLogsKeys.Detail.ColField)} + {isUpdate ? ( + <> + {t(AuditLogsKeys.Detail.ColOldValue)} + {t(AuditLogsKeys.Detail.ColNewValue)} + + ) : ( + {t(AuditLogsKeys.Detail.ColValue)} + )} + + + + {changes.map((change) => ( + + {change.field} + {isUpdate ? ( + <> + + {change.old != null ? ( + + {String(change.old)} + + ) : ( + \u2014 + )} + + + {change.new != null ? ( + + {String(change.new)} + + ) : ( + \u2014 + )} + + + ) : ( + + {change.value != null ? String(change.value) : '\u2014'} + + )} + + ))} + +
+
+
+
+ ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DetailShared.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DetailShared.tsx new file mode 100644 index 00000000..39c9cc85 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/DetailShared.tsx @@ -0,0 +1,121 @@ +import { useTranslation } from '@simplemodule/client/use-translation'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@simplemodule/ui'; +import { useEffect, useState } from 'react'; +import { AuditLogsKeys } from '@/Locales/keys'; + +export function LabeledField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children || '\u2014'}
+
+ ); +} + +export function formatJson(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +} + +export interface ChangeEntry { + field: string; + old?: unknown; + new?: unknown; + value?: unknown; +} + +export function parseChanges(raw: string): ChangeEntry[] { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return (parsed as Array>).map((item) => { + if ('old' in item || 'new' in item) { + return { field: String(item.field ?? ''), old: item.old, new: item.new }; + } + if ('oldValue' in item || 'newValue' in item) { + return { field: String(item.field ?? ''), old: item.oldValue, new: item.newValue }; + } + return { field: String(item.field ?? ''), value: item.value }; + }); + } + if (typeof parsed === 'object' && parsed !== null) { + return Object.entries(parsed).map(([field, val]) => { + if ( + typeof val === 'object' && + val !== null && + ('old' in val || 'new' in val || 'oldValue' in val || 'newValue' in val) + ) { + const v = val as { old?: unknown; new?: unknown; oldValue?: unknown; newValue?: unknown }; + return { field, old: v.old ?? v.oldValue, new: v.new ?? v.newValue }; + } + return { field, value: val }; + }); + } + return []; + } catch { + return []; + } +} + +export function hasUpdateStyle(changes: ChangeEntry[]): boolean { + return changes.some((c) => 'old' in c || 'new' in c); +} + +export function CopyButton({ text }: { text: string }) { + const { t } = useTranslation('AuditLogs'); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const id = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(id); + }, [copied]); + + function handleCopy() { + navigator.clipboard.writeText(text).catch(() => {}); + setCopied(true); + } + + return ( + + + + + + {copied ? t(AuditLogsKeys.Detail.CopyCopied) : t(AuditLogsKeys.Detail.CopyToClipboard)} + + + ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/InlineCharts.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/InlineCharts.tsx new file mode 100644 index 00000000..d20e3569 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/InlineCharts.tsx @@ -0,0 +1,147 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@simplemodule/ui'; +import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from 'recharts'; +import type { DashboardStats } from '@/types'; +import { PALETTE } from './dashboard-constants'; + +export function TimelineAreaChart({ + title, + data, + config, +}: { + title: string; + data: DashboardStats['timeline']; + config: ChartConfig; +}) { + return ( + + + {title} + + + + + + + + + + + + + + + + + + + + v.slice(5)} + /> + + } /> + + + + + + + + ); +} + +export function EntityTypesChart({ + title, + data, + config, +}: { + title: string; + data: { name: string; value: number }[]; + config: ChartConfig; +}) { + return ( + + + {title} + + + + + + + + } /> + + {data.map((d, i) => ( + + ))} + + + + + + ); +} + +export function HourlyDistributionChart({ + title, + data, + config, +}: { + title: string; + data: DashboardStats['hourlyDistribution']; + config: ChartConfig; +}) { + return ( + + + {title} + + + + + + + + } /> + + + + + + ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/KpiCard.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/KpiCard.tsx new file mode 100644 index 00000000..ccc3a439 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/KpiCard.tsx @@ -0,0 +1,31 @@ +import { Card, CardContent } from '@simplemodule/ui'; + +export function KpiCard({ + title, + value, + subtitle, + accent, + onClick, +}: { + title: string; + value: string; + subtitle?: string; + accent?: 'default' | 'danger'; + onClick?: () => void; +}) { + return ( + + +

{title}

+

+ {value} +

+ {subtitle &&

{subtitle}

} +
+
+ ); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/dashboard-constants.ts b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/dashboard-constants.ts new file mode 100644 index 00000000..6f135d2d --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/components/dashboard-constants.ts @@ -0,0 +1,41 @@ +export const PALETTE = [ + 'var(--color-primary)', + 'var(--color-info)', + 'var(--color-warning)', + 'var(--color-danger)', + 'var(--color-success-light)', + 'var(--color-accent)', + 'var(--color-muted)', + 'var(--color-primary-light)', +]; + +export const SOURCE_COLORS: Record = { + Http: 'var(--color-info)', + Domain: 'var(--color-primary)', + ChangeTracker: 'var(--color-warning)', +}; + +export const STATUS_COLORS: Record = { + '2xx': 'var(--color-success)', + '3xx': 'var(--color-info)', + '4xx': 'var(--color-warning)', + '5xx': 'var(--color-danger)', + Other: 'var(--color-muted)', +}; + +export const DATE_PRESETS = [ + { label: 'Last 24h', hours: 24 }, + { label: 'Last 7 days', hours: 168 }, + { label: 'Last 30 days', hours: 720 }, + { label: 'Last 90 days', hours: 2160 }, +]; + +export function dictToChartData(dict: Record): { name: string; value: number }[] { + return Object.entries(dict) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); +} + +export function formatDate(iso: string): string { + return iso.slice(0, 10); +} diff --git a/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.Restart.cs b/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.Restart.cs new file mode 100644 index 00000000..52140aa0 --- /dev/null +++ b/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.Restart.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using SimpleModule.BackgroundJobs.Contracts; +using SimpleModule.BackgroundJobs.Services; + +namespace BackgroundJobs.Tests.Unit; + +public sealed partial class ProgressFlushServiceLifecycleTests +{ + [Fact] + public async Task Restart_ProcessesNewEntriesAfterRestart() + { + var db = _factory.Create(); + var jobId = Guid.NewGuid(); + db.JobProgress.Add(CreateProgress(jobId)); + await db.SaveChangesAsync(); + + var channel = new ProgressChannel(NullLogger.Instance); + var service = CreateService(channel); + + // First run + await service.StartAsync(CancellationToken.None); + channel.Enqueue(new ProgressEntry(jobId, 25, "First run", null, DateTimeOffset.UtcNow)); + await Task.Delay(1500); + await service.StopAsync(CancellationToken.None); + + var afterFirst = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); + afterFirst!.ProgressPercentage.Should().Be(25); + + // Restart with a new service instance (same channel) + var service2 = CreateService(channel); + await service2.StartAsync(CancellationToken.None); + channel.Enqueue(new ProgressEntry(jobId, 80, "After restart", null, DateTimeOffset.UtcNow)); + await Task.Delay(1500); + await service2.StopAsync(CancellationToken.None); + + var afterRestart = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); + afterRestart!.ProgressPercentage.Should().Be(80); + afterRestart.ProgressMessage.Should().Be("After restart"); + } + + [Fact] + public async Task Restart_EntriesEnqueuedBetweenStopAndStart_AreProcessed() + { + var db = _factory.Create(); + var jobId = Guid.NewGuid(); + db.JobProgress.Add(CreateProgress(jobId)); + await db.SaveChangesAsync(); + + var channel = new ProgressChannel(NullLogger.Instance); + var service = CreateService(channel); + + // First run and stop + await service.StartAsync(CancellationToken.None); + await Task.Delay(200); + await service.StopAsync(CancellationToken.None); + + // Enqueue while service is stopped + channel.Enqueue( + new ProgressEntry(jobId, 50, "Enqueued while stopped", null, DateTimeOffset.UtcNow) + ); + + // Restart + var service2 = CreateService(channel); + await service2.StartAsync(CancellationToken.None); + await Task.Delay(1500); + await service2.StopAsync(CancellationToken.None); + + var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); + updated!.ProgressPercentage.Should().Be(50); + updated.ProgressMessage.Should().Be("Enqueued while stopped"); + } + + [Fact] + public async Task Restart_LogsContinueAppending() + { + var db = _factory.Create(); + var jobId = Guid.NewGuid(); + db.JobProgress.Add(CreateProgress(jobId)); + await db.SaveChangesAsync(); + + var channel = new ProgressChannel(NullLogger.Instance); + + // First run - add log + var service1 = CreateService(channel); + await service1.StartAsync(CancellationToken.None); + channel.Enqueue( + new ProgressEntry(jobId, -1, null, "Log from run 1", DateTimeOffset.UtcNow) + ); + await Task.Delay(1500); + await service1.StopAsync(CancellationToken.None); + + // Second run - add another log + var service2 = CreateService(channel); + await service2.StartAsync(CancellationToken.None); + channel.Enqueue( + new ProgressEntry(jobId, -1, null, "Log from run 2", DateTimeOffset.UtcNow) + ); + await Task.Delay(1500); + await service2.StopAsync(CancellationToken.None); + + var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); + var logs = JsonSerializer.Deserialize>(updated!.Logs!); + logs.Should().HaveCount(2); + logs![0].Message.Should().Be("Log from run 1"); + logs[1].Message.Should().Be("Log from run 2"); + } + + [Fact] + public async Task Restart_MultipleStopStartCycles_NoDataLoss() + { + var db = _factory.Create(); + var jobId = Guid.NewGuid(); + db.JobProgress.Add(CreateProgress(jobId)); + await db.SaveChangesAsync(); + + var channel = new ProgressChannel(NullLogger.Instance); + + for (var cycle = 1; cycle <= 3; cycle++) + { + var service = CreateService(channel); + await service.StartAsync(CancellationToken.None); + channel.Enqueue( + new ProgressEntry( + jobId, + cycle * 30, + $"Cycle {cycle}", + $"Log cycle {cycle}", + DateTimeOffset.UtcNow + ) + ); + await Task.Delay(1500); + await service.StopAsync(CancellationToken.None); + } + + var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); + updated!.ProgressPercentage.Should().Be(90); + updated.ProgressMessage.Should().Be("Cycle 3"); + + var logs = JsonSerializer.Deserialize>(updated.Logs!); + logs.Should().HaveCount(3); + } +} diff --git a/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.Stop.cs b/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.Stop.cs new file mode 100644 index 00000000..9774577d --- /dev/null +++ b/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.Stop.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using SimpleModule.BackgroundJobs.Contracts; +using SimpleModule.BackgroundJobs.Services; + +namespace BackgroundJobs.Tests.Unit; + +public sealed partial class ProgressFlushServiceLifecycleTests +{ + [Fact] + public async Task Stop_DrainsRemainingEntriesBeforeExiting() + { + var db = _factory.Create(); + var jobId = Guid.NewGuid(); + db.JobProgress.Add(CreateProgress(jobId)); + await db.SaveChangesAsync(); + + var channel = new ProgressChannel(NullLogger.Instance); + // Use a large flush interval so the batch timer won't flush — only the drain on stop + var service = CreateService(channel, flushIntervalMs: 30_000); + await service.StartAsync(CancellationToken.None); + + // Enqueue entries + channel.Enqueue(new ProgressEntry(jobId, 90, "Almost done", null, DateTimeOffset.UtcNow)); + channel.Enqueue( + new ProgressEntry(jobId, -1, null, "Final log before stop", DateTimeOffset.UtcNow) + ); + + // Give it a moment to pick up (WaitToReadAsync will return), then stop immediately + await Task.Delay(200); + await service.StopAsync(CancellationToken.None); + + // The drain-on-shutdown path should have flushed + var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); + updated!.ProgressPercentage.Should().Be(90); + } + + [Fact] + public async Task Stop_WithEntriesFromMultipleJobs_DrainsAll() + { + var db = _factory.Create(); + var job1 = Guid.NewGuid(); + var job2 = Guid.NewGuid(); + var job3 = Guid.NewGuid(); + db.JobProgress.AddRange(CreateProgress(job1), CreateProgress(job2), CreateProgress(job3)); + await db.SaveChangesAsync(); + + var channel = new ProgressChannel(NullLogger.Instance); + var service = CreateService(channel, flushIntervalMs: 30_000); + await service.StartAsync(CancellationToken.None); + + channel.Enqueue(new ProgressEntry(job1, 10, "Job1", null, DateTimeOffset.UtcNow)); + channel.Enqueue(new ProgressEntry(job2, 20, "Job2", null, DateTimeOffset.UtcNow)); + channel.Enqueue(new ProgressEntry(job3, 30, "Job3", null, DateTimeOffset.UtcNow)); + + await Task.Delay(200); + await service.StopAsync(CancellationToken.None); + + var verifyDb = _factory.Create(); + (await verifyDb.JobProgress.FindAsync(JobId.From(job1)))! + .ProgressPercentage.Should() + .Be(10); + (await verifyDb.JobProgress.FindAsync(JobId.From(job2)))! + .ProgressPercentage.Should() + .Be(20); + (await verifyDb.JobProgress.FindAsync(JobId.From(job3)))! + .ProgressPercentage.Should() + .Be(30); + } + + [Fact] + public async Task Stop_ChannelEmpty_CompletesGracefully() + { + var channel = new ProgressChannel(NullLogger.Instance); + var service = CreateService(channel); + await service.StartAsync(CancellationToken.None); + await Task.Delay(200); + + var act = () => service.StopAsync(CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Stop_LogsAreDrainedToo() + { + var db = _factory.Create(); + var jobId = Guid.NewGuid(); + db.JobProgress.Add(CreateProgress(jobId)); + await db.SaveChangesAsync(); + + var channel = new ProgressChannel(NullLogger.Instance); + var service = CreateService(channel, flushIntervalMs: 30_000); + await service.StartAsync(CancellationToken.None); + + channel.Enqueue( + new ProgressEntry(jobId, -1, null, "Log before shutdown", DateTimeOffset.UtcNow) + ); + + await Task.Delay(200); + await service.StopAsync(CancellationToken.None); + + var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); + updated!.Logs.Should().NotBeNull(); + var logs = JsonSerializer.Deserialize>(updated.Logs!); + logs.Should().ContainSingle().Which.Message.Should().Be("Log before shutdown"); + } +} diff --git a/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.cs b/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.cs index b687e38a..3be8f752 100644 --- a/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.cs +++ b/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Unit/ProgressFlushServiceLifecycleTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using BackgroundJobs.Tests.Helpers; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -9,7 +8,7 @@ namespace BackgroundJobs.Tests.Unit; -public sealed class ProgressFlushServiceLifecycleTests : IDisposable +public sealed partial class ProgressFlushServiceLifecycleTests : IDisposable { private readonly TestDbContextFactory _factory = new(); @@ -77,243 +76,6 @@ public async Task Start_EmptyChannel_DoesNotThrow() await act.Should().NotThrowAsync(); } - // ===== STOP ===== - - [Fact] - public async Task Stop_DrainsRemainingEntriesBeforeExiting() - { - var db = _factory.Create(); - var jobId = Guid.NewGuid(); - db.JobProgress.Add(CreateProgress(jobId)); - await db.SaveChangesAsync(); - - var channel = new ProgressChannel(NullLogger.Instance); - // Use a large flush interval so the batch timer won't flush — only the drain on stop - var service = CreateService(channel, flushIntervalMs: 30_000); - await service.StartAsync(CancellationToken.None); - - // Enqueue entries - channel.Enqueue(new ProgressEntry(jobId, 90, "Almost done", null, DateTimeOffset.UtcNow)); - channel.Enqueue( - new ProgressEntry(jobId, -1, null, "Final log before stop", DateTimeOffset.UtcNow) - ); - - // Give it a moment to pick up (WaitToReadAsync will return), then stop immediately - await Task.Delay(200); - await service.StopAsync(CancellationToken.None); - - // The drain-on-shutdown path should have flushed - var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); - updated!.ProgressPercentage.Should().Be(90); - } - - [Fact] - public async Task Stop_WithEntriesFromMultipleJobs_DrainsAll() - { - var db = _factory.Create(); - var job1 = Guid.NewGuid(); - var job2 = Guid.NewGuid(); - var job3 = Guid.NewGuid(); - db.JobProgress.AddRange(CreateProgress(job1), CreateProgress(job2), CreateProgress(job3)); - await db.SaveChangesAsync(); - - var channel = new ProgressChannel(NullLogger.Instance); - var service = CreateService(channel, flushIntervalMs: 30_000); - await service.StartAsync(CancellationToken.None); - - channel.Enqueue(new ProgressEntry(job1, 10, "Job1", null, DateTimeOffset.UtcNow)); - channel.Enqueue(new ProgressEntry(job2, 20, "Job2", null, DateTimeOffset.UtcNow)); - channel.Enqueue(new ProgressEntry(job3, 30, "Job3", null, DateTimeOffset.UtcNow)); - - await Task.Delay(200); - await service.StopAsync(CancellationToken.None); - - var verifyDb = _factory.Create(); - (await verifyDb.JobProgress.FindAsync(JobId.From(job1)))! - .ProgressPercentage.Should() - .Be(10); - (await verifyDb.JobProgress.FindAsync(JobId.From(job2)))! - .ProgressPercentage.Should() - .Be(20); - (await verifyDb.JobProgress.FindAsync(JobId.From(job3)))! - .ProgressPercentage.Should() - .Be(30); - } - - [Fact] - public async Task Stop_ChannelEmpty_CompletesGracefully() - { - var channel = new ProgressChannel(NullLogger.Instance); - var service = CreateService(channel); - await service.StartAsync(CancellationToken.None); - await Task.Delay(200); - - var act = () => service.StopAsync(CancellationToken.None); - - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task Stop_LogsAreDrainedToo() - { - var db = _factory.Create(); - var jobId = Guid.NewGuid(); - db.JobProgress.Add(CreateProgress(jobId)); - await db.SaveChangesAsync(); - - var channel = new ProgressChannel(NullLogger.Instance); - var service = CreateService(channel, flushIntervalMs: 30_000); - await service.StartAsync(CancellationToken.None); - - channel.Enqueue( - new ProgressEntry(jobId, -1, null, "Log before shutdown", DateTimeOffset.UtcNow) - ); - - await Task.Delay(200); - await service.StopAsync(CancellationToken.None); - - var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); - updated!.Logs.Should().NotBeNull(); - var logs = JsonSerializer.Deserialize>(updated.Logs!); - logs.Should().ContainSingle().Which.Message.Should().Be("Log before shutdown"); - } - - // ===== RESTART ===== - - [Fact] - public async Task Restart_ProcessesNewEntriesAfterRestart() - { - var db = _factory.Create(); - var jobId = Guid.NewGuid(); - db.JobProgress.Add(CreateProgress(jobId)); - await db.SaveChangesAsync(); - - var channel = new ProgressChannel(NullLogger.Instance); - var service = CreateService(channel); - - // First run - await service.StartAsync(CancellationToken.None); - channel.Enqueue(new ProgressEntry(jobId, 25, "First run", null, DateTimeOffset.UtcNow)); - await Task.Delay(1500); - await service.StopAsync(CancellationToken.None); - - var afterFirst = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); - afterFirst!.ProgressPercentage.Should().Be(25); - - // Restart with a new service instance (same channel) - var service2 = CreateService(channel); - await service2.StartAsync(CancellationToken.None); - channel.Enqueue(new ProgressEntry(jobId, 80, "After restart", null, DateTimeOffset.UtcNow)); - await Task.Delay(1500); - await service2.StopAsync(CancellationToken.None); - - var afterRestart = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); - afterRestart!.ProgressPercentage.Should().Be(80); - afterRestart.ProgressMessage.Should().Be("After restart"); - } - - [Fact] - public async Task Restart_EntriesEnqueuedBetweenStopAndStart_AreProcessed() - { - var db = _factory.Create(); - var jobId = Guid.NewGuid(); - db.JobProgress.Add(CreateProgress(jobId)); - await db.SaveChangesAsync(); - - var channel = new ProgressChannel(NullLogger.Instance); - var service = CreateService(channel); - - // First run and stop - await service.StartAsync(CancellationToken.None); - await Task.Delay(200); - await service.StopAsync(CancellationToken.None); - - // Enqueue while service is stopped - channel.Enqueue( - new ProgressEntry(jobId, 50, "Enqueued while stopped", null, DateTimeOffset.UtcNow) - ); - - // Restart - var service2 = CreateService(channel); - await service2.StartAsync(CancellationToken.None); - await Task.Delay(1500); - await service2.StopAsync(CancellationToken.None); - - var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); - updated!.ProgressPercentage.Should().Be(50); - updated.ProgressMessage.Should().Be("Enqueued while stopped"); - } - - [Fact] - public async Task Restart_LogsContinueAppending() - { - var db = _factory.Create(); - var jobId = Guid.NewGuid(); - db.JobProgress.Add(CreateProgress(jobId)); - await db.SaveChangesAsync(); - - var channel = new ProgressChannel(NullLogger.Instance); - - // First run - add log - var service1 = CreateService(channel); - await service1.StartAsync(CancellationToken.None); - channel.Enqueue( - new ProgressEntry(jobId, -1, null, "Log from run 1", DateTimeOffset.UtcNow) - ); - await Task.Delay(1500); - await service1.StopAsync(CancellationToken.None); - - // Second run - add another log - var service2 = CreateService(channel); - await service2.StartAsync(CancellationToken.None); - channel.Enqueue( - new ProgressEntry(jobId, -1, null, "Log from run 2", DateTimeOffset.UtcNow) - ); - await Task.Delay(1500); - await service2.StopAsync(CancellationToken.None); - - var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); - var logs = JsonSerializer.Deserialize>(updated!.Logs!); - logs.Should().HaveCount(2); - logs![0].Message.Should().Be("Log from run 1"); - logs[1].Message.Should().Be("Log from run 2"); - } - - [Fact] - public async Task Restart_MultipleStopStartCycles_NoDataLoss() - { - var db = _factory.Create(); - var jobId = Guid.NewGuid(); - db.JobProgress.Add(CreateProgress(jobId)); - await db.SaveChangesAsync(); - - var channel = new ProgressChannel(NullLogger.Instance); - - for (var cycle = 1; cycle <= 3; cycle++) - { - var service = CreateService(channel); - await service.StartAsync(CancellationToken.None); - channel.Enqueue( - new ProgressEntry( - jobId, - cycle * 30, - $"Cycle {cycle}", - $"Log cycle {cycle}", - DateTimeOffset.UtcNow - ) - ); - await Task.Delay(1500); - await service.StopAsync(CancellationToken.None); - } - - var updated = await _factory.Create().JobProgress.FindAsync(JobId.From(jobId)); - updated!.ProgressPercentage.Should().Be(90); - updated.ProgressMessage.Should().Be("Cycle 3"); - - var logs = JsonSerializer.Deserialize>(updated.Logs!); - logs.Should().HaveCount(3); - } - // ===== Helpers ===== private static JobProgress CreateProgress(Guid id) diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Authorization.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Authorization.cs new file mode 100644 index 00000000..96331afc --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Authorization.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using SimpleModule.Chat; + +namespace Chat.Tests.Integration; + +public partial class ChatEndpointTests +{ + [Fact] + public async Task ListConversations_Unauthenticated_Returns401() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/api/chat/conversations"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ListConversations_WithoutViewPermission_Returns403() + { + var client = _factory.CreateAuthenticatedClient([ChatPermissions.Create]); + + var response = await client.GetAsync("/api/chat/conversations"); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task ListConversations_WithViewPermission_Returns200() + { + var client = _factory.CreateAuthenticatedClient([ChatPermissions.View]); + + var response = await client.GetAsync("/api/chat/conversations"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task BrowseView_Unauthenticated_Returns401() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/chat"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ListConversations_OnlyReturnsCurrentUsersConversations() + { + var alice = _factory.CreateAuthenticatedClient( + [ChatPermissions.View, ChatPermissions.Create], + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "alice-list" + ) + ); + var bob = _factory.CreateAuthenticatedClient( + [ChatPermissions.View, ChatPermissions.Create], + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "bob-list" + ) + ); + + await CreateConversationAsync(alice, "assistant", "alice-1"); + await CreateConversationAsync(alice, "assistant", "alice-2"); + await CreateConversationAsync(bob, "assistant", "bob-1"); + + var aliceList = await alice.GetFromJsonAsync>("/api/chat/conversations"); + + aliceList.Should().NotBeNull(); + aliceList! + .Select(e => e.GetProperty("title").GetString()) + .Should() + .BeEquivalentTo(ExpectedAliceTitles); + } +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Create.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Create.cs new file mode 100644 index 00000000..776a4dcd --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Create.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using SimpleModule.Chat; + +namespace Chat.Tests.Integration; + +public partial class ChatEndpointTests +{ + [Fact] + public async Task CreateConversation_WithCreatePermission_Returns201() + { + var client = _factory.CreateAuthenticatedClient([ChatPermissions.Create]); + + var response = await client.PostAsJsonAsync( + "/api/chat/conversations", + new { agentName = "assistant", title = "My chat" } + ); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var json = await response.Content.ReadFromJsonAsync(); + json.GetProperty("title").GetString().Should().Be("My chat"); + json.GetProperty("agentName").GetString().Should().Be("assistant"); + } + + [Fact] + public async Task CreateConversation_WithoutPermission_Returns403() + { + var client = _factory.CreateAuthenticatedClient([ChatPermissions.View]); + + var response = await client.PostAsJsonAsync( + "/api/chat/conversations", + new { agentName = "assistant" } + ); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task CreateConversation_WithoutAgentName_Returns400() + { + var client = _factory.CreateAuthenticatedClient([ChatPermissions.Create]); + + var response = await client.PostAsJsonAsync( + "/api/chat/conversations", + new { agentName = "" } + ); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Helpers.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Helpers.cs new file mode 100644 index 00000000..eeaf93d9 --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Helpers.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Json; +using System.Text.Json; +using SimpleModule.Chat.Contracts; +using SimpleModule.Tests.Shared.Fixtures; + +namespace Chat.Tests.Integration; + +[Collection(TestCollections.Integration)] +public partial class ChatEndpointTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private static readonly string[] ExpectedAliceTitles = ["alice-1", "alice-2"]; + + private readonly SimpleModuleWebApplicationFactory _factory; + + public ChatEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + private static async Task CreateConversationAsync( + HttpClient client, + string agentName, + string title + ) + { + var response = await client.PostAsJsonAsync( + "/api/chat/conversations", + new { agentName, title } + ); + response.EnsureSuccessStatusCode(); + var created = await response.Content.ReadFromJsonAsync(JsonOptions); + return created!; + } +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Mutate.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Mutate.cs new file mode 100644 index 00000000..237ae918 --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Mutate.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using SimpleModule.Chat; + +namespace Chat.Tests.Integration; + +public partial class ChatEndpointTests +{ + [Fact] + public async Task RenameConversation_WhenOwner_UpdatesTitle() + { + var client = _factory.CreateAuthenticatedClient([ + ChatPermissions.View, + ChatPermissions.Create, + ]); + var created = await CreateConversationAsync(client, "assistant", "Before"); + + var renameResponse = await client.PatchAsJsonAsync( + $"/api/chat/conversations/{created.Id.Value}", + new { title = "After" } + ); + + renameResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await renameResponse.Content.ReadFromJsonAsync(); + json.GetProperty("title").GetString().Should().Be("After"); + } + + [Fact] + public async Task RenameConversation_WithEmptyTitle_Returns400() + { + var client = _factory.CreateAuthenticatedClient([ + ChatPermissions.View, + ChatPermissions.Create, + ]); + var created = await CreateConversationAsync(client, "assistant", "Before"); + + var response = await client.PatchAsJsonAsync( + $"/api/chat/conversations/{created.Id.Value}", + new { title = " " } + ); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task DeleteConversation_WhenOwner_Returns204() + { + var client = _factory.CreateAuthenticatedClient([ + ChatPermissions.View, + ChatPermissions.Create, + ]); + var created = await CreateConversationAsync(client, "assistant", "doomed"); + + var response = await client.DeleteAsync($"/api/chat/conversations/{created.Id.Value}"); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteConversation_OtherUsers_Returns404() + { + var ownerClient = _factory.CreateAuthenticatedClient( + [ChatPermissions.View, ChatPermissions.Create], + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "alice" + ) + ); + var created = await CreateConversationAsync(ownerClient, "assistant", "alices"); + + var mallory = _factory.CreateAuthenticatedClient( + [ChatPermissions.View, ChatPermissions.Create], + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "mallory" + ) + ); + + var response = await mallory.DeleteAsync($"/api/chat/conversations/{created.Id.Value}"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Query.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Query.cs new file mode 100644 index 00000000..8678a528 --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Query.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using SimpleModule.Chat; +using SimpleModule.Chat.Contracts; + +namespace Chat.Tests.Integration; + +public partial class ChatEndpointTests +{ + [Fact] + public async Task GetConversation_WhenOwner_Returns200() + { + var client = _factory.CreateAuthenticatedClient([ + ChatPermissions.View, + ChatPermissions.Create, + ]); + var created = await CreateConversationAsync(client, "assistant", "Owned"); + + var response = await client.GetAsync($"/api/chat/conversations/{created.Id.Value}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await response.Content.ReadFromJsonAsync(); + json.GetProperty("title").GetString().Should().Be("Owned"); + } + + [Fact] + public async Task GetConversation_WhenMissing_Returns404() + { + var client = _factory.CreateAuthenticatedClient([ChatPermissions.View]); + + var response = await client.GetAsync($"/api/chat/conversations/{Guid.NewGuid()}"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetConversation_OtherUsersConversation_Returns404() + { + var ownerClient = _factory.CreateAuthenticatedClient( + [ChatPermissions.View, ChatPermissions.Create], + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "user-a" + ) + ); + var created = await CreateConversationAsync(ownerClient, "assistant", "Private"); + + var intruderClient = _factory.CreateAuthenticatedClient( + [ChatPermissions.View], + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "user-b" + ) + ); + + var response = await intruderClient.GetAsync($"/api/chat/conversations/{created.Id.Value}"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetMessages_NewConversation_ReturnsEmptyArray() + { + var client = _factory.CreateAuthenticatedClient([ + ChatPermissions.View, + ChatPermissions.Create, + ]); + var created = await CreateConversationAsync(client, "assistant", "empty"); + + var response = await client.GetAsync( + $"/api/chat/conversations/{created.Id.Value}/messages" + ); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var messages = await response.Content.ReadFromJsonAsync>(JsonOptions); + messages.Should().NotBeNull().And.BeEmpty(); + } +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.cs deleted file mode 100644 index 82e64a2e..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.cs +++ /dev/null @@ -1,317 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using FluentAssertions; -using SimpleModule.Chat; -using SimpleModule.Chat.Contracts; -using SimpleModule.Tests.Shared.Fixtures; - -namespace Chat.Tests.Integration; - -[Collection(TestCollections.Integration)] -public class ChatEndpointTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - - private static readonly string[] ExpectedAliceTitles = ["alice-1", "alice-2"]; - - private readonly SimpleModuleWebApplicationFactory _factory; - - public ChatEndpointTests(SimpleModuleWebApplicationFactory factory) - { - _factory = factory; - } - - // ---------- authentication / authorization ---------- - - [Fact] - public async Task ListConversations_Unauthenticated_Returns401() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync("/api/chat/conversations"); - - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task ListConversations_WithoutViewPermission_Returns403() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.Create]); - - var response = await client.GetAsync("/api/chat/conversations"); - - response.StatusCode.Should().Be(HttpStatusCode.Forbidden); - } - - [Fact] - public async Task ListConversations_WithViewPermission_Returns200() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.View]); - - var response = await client.GetAsync("/api/chat/conversations"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - // ---------- create ---------- - - [Fact] - public async Task CreateConversation_WithCreatePermission_Returns201() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.Create]); - - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName = "assistant", title = "My chat" } - ); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - var json = await response.Content.ReadFromJsonAsync(); - json.GetProperty("title").GetString().Should().Be("My chat"); - json.GetProperty("agentName").GetString().Should().Be("assistant"); - } - - [Fact] - public async Task CreateConversation_WithoutPermission_Returns403() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.View]); - - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName = "assistant" } - ); - - response.StatusCode.Should().Be(HttpStatusCode.Forbidden); - } - - [Fact] - public async Task CreateConversation_WithoutAgentName_Returns400() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.Create]); - - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName = "" } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // ---------- get ---------- - - [Fact] - public async Task GetConversation_WhenOwner_Returns200() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "Owned"); - - var response = await client.GetAsync($"/api/chat/conversations/{created.Id.Value}"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var json = await response.Content.ReadFromJsonAsync(); - json.GetProperty("title").GetString().Should().Be("Owned"); - } - - [Fact] - public async Task GetConversation_WhenMissing_Returns404() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.View]); - - var response = await client.GetAsync($"/api/chat/conversations/{Guid.NewGuid()}"); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task GetConversation_OtherUsersConversation_Returns404() - { - var ownerClient = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "user-a" - ) - ); - var created = await CreateConversationAsync(ownerClient, "assistant", "Private"); - - var intruderClient = _factory.CreateAuthenticatedClient( - [ChatPermissions.View], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "user-b" - ) - ); - - var response = await intruderClient.GetAsync($"/api/chat/conversations/{created.Id.Value}"); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ---------- rename ---------- - - [Fact] - public async Task RenameConversation_WhenOwner_UpdatesTitle() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "Before"); - - var renameResponse = await client.PatchAsJsonAsync( - $"/api/chat/conversations/{created.Id.Value}", - new { title = "After" } - ); - - renameResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var json = await renameResponse.Content.ReadFromJsonAsync(); - json.GetProperty("title").GetString().Should().Be("After"); - } - - [Fact] - public async Task RenameConversation_WithEmptyTitle_Returns400() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "Before"); - - var response = await client.PatchAsJsonAsync( - $"/api/chat/conversations/{created.Id.Value}", - new { title = " " } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // ---------- delete ---------- - - [Fact] - public async Task DeleteConversation_WhenOwner_Returns204() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "doomed"); - - var response = await client.DeleteAsync($"/api/chat/conversations/{created.Id.Value}"); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task DeleteConversation_OtherUsers_Returns404() - { - var ownerClient = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "alice" - ) - ); - var created = await CreateConversationAsync(ownerClient, "assistant", "alices"); - - var mallory = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "mallory" - ) - ); - - var response = await mallory.DeleteAsync($"/api/chat/conversations/{created.Id.Value}"); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ---------- messages endpoint ---------- - - [Fact] - public async Task GetMessages_NewConversation_ReturnsEmptyArray() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "empty"); - - var response = await client.GetAsync( - $"/api/chat/conversations/{created.Id.Value}/messages" - ); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var messages = await response.Content.ReadFromJsonAsync>(JsonOptions); - messages.Should().NotBeNull().And.BeEmpty(); - } - - // ---------- browse view ---------- - - [Fact] - public async Task BrowseView_Unauthenticated_Returns401() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync("/chat"); - - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - // ---------- user isolation across requests ---------- - - [Fact] - public async Task ListConversations_OnlyReturnsCurrentUsersConversations() - { - var alice = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "alice-list" - ) - ); - var bob = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "bob-list" - ) - ); - - await CreateConversationAsync(alice, "assistant", "alice-1"); - await CreateConversationAsync(alice, "assistant", "alice-2"); - await CreateConversationAsync(bob, "assistant", "bob-1"); - - var aliceList = await alice.GetFromJsonAsync>("/api/chat/conversations"); - - aliceList.Should().NotBeNull(); - aliceList! - .Select(e => e.GetProperty("title").GetString()) - .Should() - .BeEquivalentTo(ExpectedAliceTitles); - } - - // ---------- helpers ---------- - - private static async Task CreateConversationAsync( - HttpClient client, - string agentName, - string title - ) - { - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName, title } - ); - response.EnsureSuccessStatusCode(); - var created = await response.Content.ReadFromJsonAsync(JsonOptions); - return created!; - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.ErrorHandling.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.ErrorHandling.cs new file mode 100644 index 00000000..82e18159 --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.ErrorHandling.cs @@ -0,0 +1,84 @@ +// HttpClient instances come from WebApplicationFactory.CreateClient and are owned +// by the short-lived test-scoped factory; explicit disposal adds no value. +#pragma warning disable CA2000 +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using SimpleModule.Chat; + +namespace Chat.Tests.Integration; + +public partial class ChatStreamingEndpointTests +{ + [Fact] + public async Task Stream_WhenChatClientThrowsImmediately_EmitsErrorChunk() + { + using var factory = CreateFactoryWithSpecificClient( + new RecordingChatClient(["ignored"], throwAfterTokens: 0, throwMessage: "upstream down") + ); + var client = CreateAuthenticated(factory, ChatPermissions.Create); + var conversation = await CreateConversationAsync(client); + + using var response = await client.PostAsJsonAsync( + $"/api/chat/conversations/{conversation.Id.Value}/stream", + new { messages = new[] { new { role = "user", content = "hi" } } } + ); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + var frames = ParseSseFrames(body); + + // Expect: error chunk, then [DONE]. + frames.Should().HaveCount(2); + var error = ParseJson(frames[0]); + error.GetProperty("type").GetString().Should().Be("error"); + error.GetProperty("error").GetProperty("message").GetString().Should().Be("upstream down"); + error.GetProperty("error").GetProperty("code").GetString().Should().Be("agent_error"); + frames[1].Should().Be("[DONE]"); + } + + [Fact] + public async Task Stream_WhenChatClientThrowsMidway_EmitsContentThenError() + { + using var factory = CreateFactoryWithSpecificClient( + new RecordingChatClient( + ["Hello", " world", "!"], + throwAfterTokens: 2, + throwMessage: "network blip" + ) + ); + var client = CreateAuthenticated(factory, ChatPermissions.View, ChatPermissions.Create); + var conversation = await CreateConversationAsync(client); + + using var response = await client.PostAsJsonAsync( + $"/api/chat/conversations/{conversation.Id.Value}/stream", + new { messages = new[] { new { role = "user", content = "hi" } } } + ); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var frames = ParseSseFrames(await response.Content.ReadAsStringAsync()); + + // Expect: 2 content chunks, 1 error chunk, 1 [DONE]. + frames.Should().HaveCount(4); + var firstContent = ParseJson(frames[0]); + firstContent.GetProperty("type").GetString().Should().Be("content"); + firstContent.GetProperty("delta").GetString().Should().Be("Hello"); + var secondContent = ParseJson(frames[1]); + secondContent.GetProperty("delta").GetString().Should().Be(" world"); + var error = ParseJson(frames[2]); + error.GetProperty("type").GetString().Should().Be("error"); + error.GetProperty("error").GetProperty("message").GetString().Should().Be("network blip"); + frames[3].Should().Be("[DONE]"); + + // The partial assistant reply should still be persisted so the user can see + // what the model produced before failing. + var history = await client.GetAsync( + $"/api/chat/conversations/{conversation.Id.Value}/messages" + ); + var messages = await history.Content.ReadFromJsonAsync>(JsonOptions); + messages.Should().NotBeNull(); + messages!.Should().HaveCount(2); + messages[1].GetProperty("content").GetString().Should().Be("Hello world"); + } +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.HappyPath.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.HappyPath.cs new file mode 100644 index 00000000..e258806a --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.HappyPath.cs @@ -0,0 +1,119 @@ +// HttpClient instances come from WebApplicationFactory.CreateClient and are owned +// by the short-lived test-scoped factory; explicit disposal adds no value. +#pragma warning disable CA2000 +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using SimpleModule.Chat; +using AiChatRole = Microsoft.Extensions.AI.ChatRole; + +namespace Chat.Tests.Integration; + +public partial class ChatStreamingEndpointTests +{ + [Fact] + public async Task Stream_EmitsTanStackContentChunks() + { + using var factory = CreateFactoryWithFakes(["Hello", " ", "world"]); + var client = CreateAuthenticated(factory, ChatPermissions.Create); + var conversation = await CreateConversationAsync(client); + + using var response = await client.PostAsJsonAsync( + $"/api/chat/conversations/{conversation.Id.Value}/stream", + new { messages = new[] { new { role = "user", content = "say hi" } } } + ); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.Should().Be("text/event-stream"); + + var body = await response.Content.ReadAsStringAsync(); + var frames = ParseSseFrames(body); + + // Three content chunks + one done chunk + final [DONE] sentinel. + frames.Should().HaveCount(5); + + var contentChunks = frames.Take(3).Select(ParseJson).ToArray(); + contentChunks + .Should() + .AllSatisfy(f => f.GetProperty("type").GetString().Should().Be("content")); + contentChunks + .Select(f => f.GetProperty("delta").GetString()) + .Should() + .Equal("Hello", " ", "world"); + // Content mirrors delta per chunk (clients accumulate via useChat). + contentChunks[2].GetProperty("content").GetString().Should().Be("world"); + contentChunks + .Select(f => f.GetProperty("role").GetString()) + .Should() + .AllBeEquivalentTo("assistant"); + + var done = ParseJson(frames[3]); + done.GetProperty("type").GetString().Should().Be("done"); + done.GetProperty("finishReason").GetString().Should().Be("stop"); + + frames[4].Should().Be("[DONE]"); + } + + [Fact] + public async Task Stream_PersistsUserAndAssistantMessages() + { + using var factory = CreateFactoryWithFakes(["Reply"]); + var client = CreateAuthenticated(factory, ChatPermissions.View, ChatPermissions.Create); + var conversation = await CreateConversationAsync(client); + + var streamResponse = await client.PostAsJsonAsync( + $"/api/chat/conversations/{conversation.Id.Value}/stream", + new { messages = new[] { new { role = "user", content = "question" } } } + ); + streamResponse.EnsureSuccessStatusCode(); + _ = await streamResponse.Content.ReadAsStringAsync(); + + var historyResponse = await client.GetAsync( + $"/api/chat/conversations/{conversation.Id.Value}/messages" + ); + historyResponse.EnsureSuccessStatusCode(); + var messages = await historyResponse.Content.ReadFromJsonAsync>( + JsonOptions + ); + + messages.Should().NotBeNull(); + messages!.Should().HaveCount(2); + messages[0].GetProperty("content").GetString().Should().Be("question"); + messages[1].GetProperty("content").GetString().Should().Be("Reply"); + } + + [Fact] + public async Task Stream_ForwardsHistoryFromTanStackPayloadToChatClient() + { + var fakeClient = new RecordingChatClient(["ok"]); + using var factory = CreateFactoryWithSpecificClient(fakeClient); + var client = CreateAuthenticated(factory, ChatPermissions.Create); + var conversation = await CreateConversationAsync(client); + + _ = await client.PostAsJsonAsync( + $"/api/chat/conversations/{conversation.Id.Value}/stream", + new + { + messages = new[] + { + new { role = "user", content = "first turn" }, + new { role = "assistant", content = "first reply" }, + new { role = "user", content = "second turn" }, + }, + } + ); + + var captured = fakeClient.LastMessages; + captured.Should().NotBeNull(); + // Expected order: [system, user(history), assistant(history), user(new)] + var roles = captured!.Select(m => m.Role).ToArray(); + roles + .Should() + .Equal(AiChatRole.System, AiChatRole.User, AiChatRole.Assistant, AiChatRole.User); + captured + .Select(m => m.Text) + .Should() + .Equal("You are a test agent.", "first turn", "first reply", "second turn"); + } +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Helpers.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Helpers.cs new file mode 100644 index 00000000..fe573da1 --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Helpers.cs @@ -0,0 +1,190 @@ +// HttpClient instances come from WebApplicationFactory.CreateClient and are owned +// by the short-lived test-scoped factory; explicit disposal adds no value. +#pragma warning disable CA2000 +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Agents; +using SimpleModule.Chat.Contracts; +using SimpleModule.Core.Agents; +using SimpleModule.Host; +using SimpleModule.Tests.Shared.Fixtures; +using AiChatRole = Microsoft.Extensions.AI.ChatRole; + +namespace Chat.Tests.Integration; + +[Collection(TestCollections.Integration)] +public partial class ChatStreamingEndpointTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly SimpleModuleWebApplicationFactory _baseFactory; + + public ChatStreamingEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _baseFactory = factory; + // Force the base factory to initialize its in-memory SQLite database so that + // schema exists on the shared connection before any delegate factory uses it. + _ = factory.CreateAuthenticatedClient(Array.Empty()); + } + + private static async Task CreateConversationAsync(HttpClient client) + { + var response = await client.PostAsJsonAsync( + "/api/chat/conversations", + new { agentName = "test-agent", title = "Streaming test" } + ); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private WebApplicationFactory CreateFactoryWithFakes(string[] tokens) => + CreateFactoryWithSpecificClient(new RecordingChatClient(tokens)); + + private WebApplicationFactory CreateFactoryWithSpecificClient( + IChatClient chatClient + ) => + _baseFactory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Replace IChatClient with our recording fake. + var chatClientDescriptor = services.SingleOrDefault(d => + d.ServiceType == typeof(IChatClient) + ); + if (chatClientDescriptor is not null) + { + services.Remove(chatClientDescriptor); + } + services.AddSingleton(chatClient); + + // Replace IAgentRegistry with a single test agent. + var registryDescriptor = services.SingleOrDefault(d => + d.ServiceType == typeof(IAgentRegistry) + ); + if (registryDescriptor is not null) + { + services.Remove(registryDescriptor); + } + var registry = new AgentRegistry(); + registry.Register( + new AgentRegistration( + Name: "test-agent", + Description: "Integration test agent", + ModuleName: "Chat.Tests", + AgentDefinitionType: typeof(StreamingTestAgent), + ToolProviderTypes: Array.Empty() + ) + ); + services.AddSingleton(registry); + }); + }); + + private static HttpClient CreateAuthenticated( + WebApplicationFactory factory, + params string[] permissions + ) => CreateAuthenticatedAs(factory, "test-user-id", permissions); + + private static HttpClient CreateAuthenticatedAs( + WebApplicationFactory factory, + string userId, + params string[] permissions + ) + { + var client = factory.CreateClient(); + var claims = new List { new(ClaimTypes.NameIdentifier, userId) }; + claims.AddRange(permissions.Select(p => new Claim("permission", p))); + var headerValue = string.Join(";", claims.Select(c => $"{c.Type}={c.Value}")); + client.DefaultRequestHeaders.Add("X-Test-Claims", headerValue); + return client; + } + + private static List ParseSseFrames(string body) + { + // Each SSE frame is "data: \n\n" + var frames = new List(); + foreach ( + var segment in body.Split( + "\n\n", + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ) + ) + { + if (segment.StartsWith("data: ", StringComparison.Ordinal)) + { + frames.Add(segment[6..]); + } + } + return frames; + } + + private static JsonElement ParseJson(string raw) => JsonDocument.Parse(raw).RootElement.Clone(); + + // ---------- fakes ---------- + + internal sealed class StreamingTestAgent : IAgentDefinition + { + public string Name => "test-agent"; + public string Description => "Test"; + public string Instructions => "You are a test agent."; + public bool? EnableRag => false; + } + + internal sealed class RecordingChatClient( + string[] tokens, + int? throwAfterTokens = null, + string throwMessage = "boom" + ) : IChatClient + { + public IList? LastMessages { get; private set; } + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default + ) + { + LastMessages = messages.ToList(); + var response = new ChatResponse( + new Microsoft.Extensions.AI.ChatMessage(AiChatRole.Assistant, string.Concat(tokens)) + ); + return Task.FromResult(response); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken = default + ) + { + LastMessages = messages.ToList(); + var emitted = 0; + foreach (var token in tokens) + { + if (throwAfterTokens is { } limit && emitted >= limit) + { + throw new InvalidOperationException(throwMessage); + } + yield return new ChatResponseUpdate(AiChatRole.Assistant, token); + emitted++; + } + if (throwAfterTokens == 0) + { + throw new InvalidOperationException(throwMessage); + } + await Task.CompletedTask; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Validation.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Validation.cs new file mode 100644 index 00000000..fff28215 --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Validation.cs @@ -0,0 +1,94 @@ +// HttpClient instances come from WebApplicationFactory.CreateClient and are owned +// by the short-lived test-scoped factory; explicit disposal adds no value. +#pragma warning disable CA2000 +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using SimpleModule.Chat; + +namespace Chat.Tests.Integration; + +public partial class ChatStreamingEndpointTests +{ + [Fact] + public async Task Stream_MissingMessagesArray_Returns400() + { + using var factory = CreateFactoryWithFakes(["ignored"]); + var client = CreateAuthenticated(factory, ChatPermissions.Create); + var conversation = await CreateConversationAsync(client); + + var response = await client.PostAsJsonAsync( + $"/api/chat/conversations/{conversation.Id.Value}/stream", + new { messages = Array.Empty() } + ); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Stream_OnlyAssistantMessages_Returns400() + { + using var factory = CreateFactoryWithFakes(["ignored"]); + var client = CreateAuthenticated(factory, ChatPermissions.Create); + var conversation = await CreateConversationAsync(client); + + var response = await client.PostAsJsonAsync( + $"/api/chat/conversations/{conversation.Id.Value}/stream", + new { messages = new[] { new { role = "assistant", content = "hi" } } } + ); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Stream_WhitespaceLatestUserMessage_Returns400() + { + using var factory = CreateFactoryWithFakes(["ignored"]); + var client = CreateAuthenticated(factory, ChatPermissions.Create); + var conversation = await CreateConversationAsync(client); + + var response = await client.PostAsJsonAsync( + $"/api/chat/conversations/{conversation.Id.Value}/stream", + new { messages = new[] { new { role = "user", content = " " } } } + ); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Stream_NonExistentConversation_Returns404() + { + using var factory = CreateFactoryWithFakes(["x"]); + var client = CreateAuthenticated(factory, ChatPermissions.Create); + + var response = await client.PostAsJsonAsync( + $"/api/chat/conversations/{Guid.NewGuid()}/stream", + new { messages = new[] { new { role = "user", content = "hi" } } } + ); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Stream_WithoutCreatePermission_Returns403() + { + using var factory = CreateFactoryWithFakes(["x"]); + // Owner creates the conversation with Create permission. + var ownerClient = CreateAuthenticatedAs( + factory, + "owner-user", + ChatPermissions.View, + ChatPermissions.Create + ); + var conversation = await CreateConversationAsync(ownerClient); + + // Viewer with only View tries to send a message. + var viewerClient = CreateAuthenticatedAs(factory, "viewer-user", ChatPermissions.View); + var response = await viewerClient.PostAsJsonAsync( + $"/api/chat/conversations/{conversation.Id.Value}/stream", + new { messages = new[] { new { role = "user", content = "hi" } } } + ); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.cs deleted file mode 100644 index 8a609dcf..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.cs +++ /dev/null @@ -1,459 +0,0 @@ -// HttpClient instances come from WebApplicationFactory.CreateClient and are owned -// by the short-lived test-scoped factory; explicit disposal adds no value. -#pragma warning disable CA2000 -using System.Net; -using System.Net.Http.Json; -using System.Security.Claims; -using System.Text.Json; -using FluentAssertions; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using SimpleModule.Agents; -using SimpleModule.Chat; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core.Agents; -using SimpleModule.Host; -using SimpleModule.Tests.Shared.Fixtures; -using AiChatRole = Microsoft.Extensions.AI.ChatRole; - -namespace Chat.Tests.Integration; - -[Collection(TestCollections.Integration)] -public class ChatStreamingEndpointTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - - private readonly SimpleModuleWebApplicationFactory _baseFactory; - - public ChatStreamingEndpointTests(SimpleModuleWebApplicationFactory factory) - { - _baseFactory = factory; - // Force the base factory to initialize its in-memory SQLite database so that - // schema exists on the shared connection before any delegate factory uses it. - _ = factory.CreateAuthenticatedClient(Array.Empty()); - } - - // ---------- validation ---------- - - [Fact] - public async Task Stream_MissingMessagesArray_Returns400() - { - using var factory = CreateFactoryWithFakes(["ignored"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = Array.Empty() } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Stream_OnlyAssistantMessages_Returns400() - { - using var factory = CreateFactoryWithFakes(["ignored"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "assistant", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Stream_WhitespaceLatestUserMessage_Returns400() - { - using var factory = CreateFactoryWithFakes(["ignored"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = " " } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Stream_NonExistentConversation_Returns404() - { - using var factory = CreateFactoryWithFakes(["x"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - - var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{Guid.NewGuid()}/stream", - new { messages = new[] { new { role = "user", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Stream_WithoutCreatePermission_Returns403() - { - using var factory = CreateFactoryWithFakes(["x"]); - // Owner creates the conversation with Create permission. - var ownerClient = CreateAuthenticatedAs( - factory, - "owner-user", - ChatPermissions.View, - ChatPermissions.Create - ); - var conversation = await CreateConversationAsync(ownerClient); - - // Viewer with only View tries to send a message. - var viewerClient = CreateAuthenticatedAs(factory, "viewer-user", ChatPermissions.View); - var response = await viewerClient.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.Forbidden); - } - - // ---------- happy path: SSE wire format ---------- - - [Fact] - public async Task Stream_EmitsTanStackContentChunks() - { - using var factory = CreateFactoryWithFakes(["Hello", " ", "world"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - using var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "say hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Headers.ContentType?.MediaType.Should().Be("text/event-stream"); - - var body = await response.Content.ReadAsStringAsync(); - var frames = ParseSseFrames(body); - - // Three content chunks + one done chunk + final [DONE] sentinel. - frames.Should().HaveCount(5); - - var contentChunks = frames.Take(3).Select(ParseJson).ToArray(); - contentChunks - .Should() - .AllSatisfy(f => f.GetProperty("type").GetString().Should().Be("content")); - contentChunks - .Select(f => f.GetProperty("delta").GetString()) - .Should() - .Equal("Hello", " ", "world"); - // Content mirrors delta per chunk (clients accumulate via useChat). - contentChunks[2].GetProperty("content").GetString().Should().Be("world"); - contentChunks - .Select(f => f.GetProperty("role").GetString()) - .Should() - .AllBeEquivalentTo("assistant"); - - var done = ParseJson(frames[3]); - done.GetProperty("type").GetString().Should().Be("done"); - done.GetProperty("finishReason").GetString().Should().Be("stop"); - - frames[4].Should().Be("[DONE]"); - } - - [Fact] - public async Task Stream_PersistsUserAndAssistantMessages() - { - using var factory = CreateFactoryWithFakes(["Reply"]); - var client = CreateAuthenticated(factory, ChatPermissions.View, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - var streamResponse = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "question" } } } - ); - streamResponse.EnsureSuccessStatusCode(); - _ = await streamResponse.Content.ReadAsStringAsync(); - - var historyResponse = await client.GetAsync( - $"/api/chat/conversations/{conversation.Id.Value}/messages" - ); - historyResponse.EnsureSuccessStatusCode(); - var messages = await historyResponse.Content.ReadFromJsonAsync>( - JsonOptions - ); - - messages.Should().NotBeNull(); - messages!.Should().HaveCount(2); - messages[0].GetProperty("content").GetString().Should().Be("question"); - messages[1].GetProperty("content").GetString().Should().Be("Reply"); - } - - [Fact] - public async Task Stream_ForwardsHistoryFromTanStackPayloadToChatClient() - { - var fakeClient = new RecordingChatClient(["ok"]); - using var factory = CreateFactoryWithSpecificClient(fakeClient); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - _ = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new - { - messages = new[] - { - new { role = "user", content = "first turn" }, - new { role = "assistant", content = "first reply" }, - new { role = "user", content = "second turn" }, - }, - } - ); - - var captured = fakeClient.LastMessages; - captured.Should().NotBeNull(); - // Expected order: [system, user(history), assistant(history), user(new)] - var roles = captured!.Select(m => m.Role).ToArray(); - roles - .Should() - .Equal(AiChatRole.System, AiChatRole.User, AiChatRole.Assistant, AiChatRole.User); - captured - .Select(m => m.Text) - .Should() - .Equal("You are a test agent.", "first turn", "first reply", "second turn"); - } - - // ---------- error handling ---------- - - [Fact] - public async Task Stream_WhenChatClientThrowsImmediately_EmitsErrorChunk() - { - using var factory = CreateFactoryWithSpecificClient( - new RecordingChatClient(["ignored"], throwAfterTokens: 0, throwMessage: "upstream down") - ); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - using var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - var frames = ParseSseFrames(body); - - // Expect: error chunk, then [DONE]. - frames.Should().HaveCount(2); - var error = ParseJson(frames[0]); - error.GetProperty("type").GetString().Should().Be("error"); - error.GetProperty("error").GetProperty("message").GetString().Should().Be("upstream down"); - error.GetProperty("error").GetProperty("code").GetString().Should().Be("agent_error"); - frames[1].Should().Be("[DONE]"); - } - - [Fact] - public async Task Stream_WhenChatClientThrowsMidway_EmitsContentThenError() - { - using var factory = CreateFactoryWithSpecificClient( - new RecordingChatClient( - ["Hello", " world", "!"], - throwAfterTokens: 2, - throwMessage: "network blip" - ) - ); - var client = CreateAuthenticated(factory, ChatPermissions.View, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - using var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var frames = ParseSseFrames(await response.Content.ReadAsStringAsync()); - - // Expect: 2 content chunks, 1 error chunk, 1 [DONE]. - frames.Should().HaveCount(4); - var firstContent = ParseJson(frames[0]); - firstContent.GetProperty("type").GetString().Should().Be("content"); - firstContent.GetProperty("delta").GetString().Should().Be("Hello"); - var secondContent = ParseJson(frames[1]); - secondContent.GetProperty("delta").GetString().Should().Be(" world"); - var error = ParseJson(frames[2]); - error.GetProperty("type").GetString().Should().Be("error"); - error.GetProperty("error").GetProperty("message").GetString().Should().Be("network blip"); - frames[3].Should().Be("[DONE]"); - - // The partial assistant reply should still be persisted so the user can see - // what the model produced before failing. - var history = await client.GetAsync( - $"/api/chat/conversations/{conversation.Id.Value}/messages" - ); - var messages = await history.Content.ReadFromJsonAsync>(JsonOptions); - messages.Should().NotBeNull(); - messages!.Should().HaveCount(2); - messages[1].GetProperty("content").GetString().Should().Be("Hello world"); - } - - // ---------- helpers ---------- - - private static async Task CreateConversationAsync(HttpClient client) - { - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName = "test-agent", title = "Streaming test" } - ); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync(JsonOptions))!; - } - - private WebApplicationFactory CreateFactoryWithFakes(string[] tokens) => - CreateFactoryWithSpecificClient(new RecordingChatClient(tokens)); - - private WebApplicationFactory CreateFactoryWithSpecificClient( - IChatClient chatClient - ) => - _baseFactory.WithWebHostBuilder(builder => - { - builder.ConfigureServices(services => - { - // Replace IChatClient with our recording fake. - var chatClientDescriptor = services.SingleOrDefault(d => - d.ServiceType == typeof(IChatClient) - ); - if (chatClientDescriptor is not null) - { - services.Remove(chatClientDescriptor); - } - services.AddSingleton(chatClient); - - // Replace IAgentRegistry with a single test agent. - var registryDescriptor = services.SingleOrDefault(d => - d.ServiceType == typeof(IAgentRegistry) - ); - if (registryDescriptor is not null) - { - services.Remove(registryDescriptor); - } - var registry = new AgentRegistry(); - registry.Register( - new AgentRegistration( - Name: "test-agent", - Description: "Integration test agent", - ModuleName: "Chat.Tests", - AgentDefinitionType: typeof(StreamingTestAgent), - ToolProviderTypes: Array.Empty() - ) - ); - services.AddSingleton(registry); - }); - }); - - private static HttpClient CreateAuthenticated( - WebApplicationFactory factory, - params string[] permissions - ) => CreateAuthenticatedAs(factory, "test-user-id", permissions); - - private static HttpClient CreateAuthenticatedAs( - WebApplicationFactory factory, - string userId, - params string[] permissions - ) - { - var client = factory.CreateClient(); - var claims = new List { new(ClaimTypes.NameIdentifier, userId) }; - claims.AddRange(permissions.Select(p => new Claim("permission", p))); - var headerValue = string.Join(";", claims.Select(c => $"{c.Type}={c.Value}")); - client.DefaultRequestHeaders.Add("X-Test-Claims", headerValue); - return client; - } - - private static List ParseSseFrames(string body) - { - // Each SSE frame is "data: \n\n" - var frames = new List(); - foreach ( - var segment in body.Split( - "\n\n", - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries - ) - ) - { - if (segment.StartsWith("data: ", StringComparison.Ordinal)) - { - frames.Add(segment[6..]); - } - } - return frames; - } - - private static JsonElement ParseJson(string raw) => JsonDocument.Parse(raw).RootElement.Clone(); - - // ---------- fakes ---------- - - internal sealed class StreamingTestAgent : IAgentDefinition - { - public string Name => "test-agent"; - public string Description => "Test"; - public string Instructions => "You are a test agent."; - public bool? EnableRag => false; - } - - internal sealed class RecordingChatClient( - string[] tokens, - int? throwAfterTokens = null, - string throwMessage = "boom" - ) : IChatClient - { - public IList? LastMessages { get; private set; } - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default - ) - { - LastMessages = messages.ToList(); - var response = new ChatResponse( - new Microsoft.Extensions.AI.ChatMessage(AiChatRole.Assistant, string.Concat(tokens)) - ); - return Task.FromResult(response); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] - CancellationToken cancellationToken = default - ) - { - LastMessages = messages.ToList(); - var emitted = 0; - foreach (var token in tokens) - { - if (throwAfterTokens is { } limit && emitted >= limit) - { - throw new InvalidOperationException(throwMessage); - } - yield return new ChatResponseUpdate(AiChatRole.Assistant, token); - emitted++; - } - if (throwAfterTokens == 0) - { - throw new InvalidOperationException(throwMessage); - } - await Task.CompletedTask; - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Conversation.cs similarity index 63% rename from modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.cs rename to modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Conversation.cs index b9131ff0..f7409c4e 100644 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.cs +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Conversation.cs @@ -1,41 +1,12 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using SimpleModule.Chat; using SimpleModule.Chat.Contracts; using SimpleModule.Core.Exceptions; -using SimpleModule.Database; namespace Chat.Tests.Unit; -public sealed class ChatServiceTests : IDisposable +public sealed partial class ChatServiceTests { - private readonly ChatDbContext _db; - private readonly ChatService _sut; - - public ChatServiceTests() - { - var options = new DbContextOptionsBuilder() - .UseSqlite("Data Source=:memory:") - .Options; - var dbOptions = Options.Create( - new DatabaseOptions - { - ModuleConnections = new Dictionary - { - ["Chat"] = "Data Source=:memory:", - }, - } - ); - _db = new ChatDbContext(options, dbOptions); - _db.Database.OpenConnection(); - _db.Database.EnsureCreated(); - _sut = new ChatService(_db, NullLogger.Instance); - } - - public void Dispose() => _db.Dispose(); - // ---------- StartConversationAsync ---------- [Fact] @@ -86,62 +57,6 @@ public async Task StartConversationAsync_IsPersistedToDatabase() reloaded!.Id.Should().Be(conv.Id); } - // ---------- AppendMessageAsync ---------- - - [Fact] - public async Task AppendMessageAsync_UpdatesConversationTimestamp() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - var originalUpdatedAt = conv.UpdatedAt; - - await Task.Delay(10); - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "hello"); - - var reloaded = await _sut.GetConversationAsync(conv.Id); - reloaded!.UpdatedAt.Should().BeAfter(originalUpdatedAt); - reloaded.Messages.Should().HaveCount(1); - reloaded.Messages[0].Content.Should().Be("hello"); - } - - [Fact] - public async Task AppendMessageAsync_PersistsAssistantRoleCorrectly() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - await _sut.AppendMessageAsync(conv.Id, ChatRole.Assistant, "Hi there!"); - - var messages = await _sut.GetMessagesAsync(conv.Id, "user-1"); - messages.Should().HaveCount(1); - messages[0].Role.Should().Be(ChatRole.Assistant); - messages[0].Content.Should().Be("Hi there!"); - } - - [Fact] - public async Task AppendMessageAsync_OrdersChronologically() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "first"); - await Task.Delay(5); - await _sut.AppendMessageAsync(conv.Id, ChatRole.Assistant, "second"); - await Task.Delay(5); - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "third"); - - var messages = await _sut.GetMessagesAsync(conv.Id, "user-1"); - messages.Select(m => m.Content).Should().Equal("first", "second", "third"); - } - - [Fact] - public async Task AppendMessageAsync_GeneratesUniqueIds() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var a = await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "one"); - var b = await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "two"); - - a.Id.Should().NotBe(b.Id); - } - // ---------- GetUserConversationsAsync ---------- [Fact] @@ -332,43 +247,4 @@ public async Task DeleteAsync_OnlyDeletesMessagesFromThatConversation() remaining.Should().HaveCount(1); remaining[0].Content.Should().Be("keep-me"); } - - // ---------- GetMessagesAsync ---------- - - [Fact] - public async Task GetMessagesAsync_EmptyWhenNone() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var messages = await _sut.GetMessagesAsync(conv.Id, "user-1"); - - messages.Should().BeEmpty(); - } - - [Fact] - public async Task GetMessagesAsync_ThrowsForNonOwner() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "secret"); - - var act = () => _sut.GetMessagesAsync(conv.Id, "user-2"); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task GetMessagesAsync_IsolatesPerConversation() - { - var a = await _sut.StartConversationAsync("user-1", "assistant", null); - var b = await _sut.StartConversationAsync("user-1", "assistant", null); - await _sut.AppendMessageAsync(a.Id, ChatRole.User, "a1"); - await _sut.AppendMessageAsync(b.Id, ChatRole.User, "b1"); - await _sut.AppendMessageAsync(a.Id, ChatRole.Assistant, "a2"); - - var aMessages = await _sut.GetMessagesAsync(a.Id, "user-1"); - var bMessages = await _sut.GetMessagesAsync(b.Id, "user-1"); - - aMessages.Select(m => m.Content).Should().Equal("a1", "a2"); - bMessages.Select(m => m.Content).Should().Equal("b1"); - } } diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Helpers.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Helpers.cs new file mode 100644 index 00000000..318ed232 --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Helpers.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SimpleModule.Chat; +using SimpleModule.Database; + +namespace Chat.Tests.Unit; + +public sealed partial class ChatServiceTests : IDisposable +{ + private readonly ChatDbContext _db; + private readonly ChatService _sut; + + public ChatServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + var dbOptions = Options.Create( + new DatabaseOptions + { + ModuleConnections = new Dictionary + { + ["Chat"] = "Data Source=:memory:", + }, + } + ); + _db = new ChatDbContext(options, dbOptions); + _db.Database.OpenConnection(); + _db.Database.EnsureCreated(); + _sut = new ChatService(_db, NullLogger.Instance); + } + + public void Dispose() => _db.Dispose(); +} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Messages.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Messages.cs new file mode 100644 index 00000000..236554e6 --- /dev/null +++ b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Messages.cs @@ -0,0 +1,103 @@ +using FluentAssertions; +using SimpleModule.Chat.Contracts; +using SimpleModule.Core.Exceptions; + +namespace Chat.Tests.Unit; + +public sealed partial class ChatServiceTests +{ + // ---------- AppendMessageAsync ---------- + + [Fact] + public async Task AppendMessageAsync_UpdatesConversationTimestamp() + { + var conv = await _sut.StartConversationAsync("user-1", "assistant", null); + var originalUpdatedAt = conv.UpdatedAt; + + await Task.Delay(10); + await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "hello"); + + var reloaded = await _sut.GetConversationAsync(conv.Id); + reloaded!.UpdatedAt.Should().BeAfter(originalUpdatedAt); + reloaded.Messages.Should().HaveCount(1); + reloaded.Messages[0].Content.Should().Be("hello"); + } + + [Fact] + public async Task AppendMessageAsync_PersistsAssistantRoleCorrectly() + { + var conv = await _sut.StartConversationAsync("user-1", "assistant", null); + + await _sut.AppendMessageAsync(conv.Id, ChatRole.Assistant, "Hi there!"); + + var messages = await _sut.GetMessagesAsync(conv.Id, "user-1"); + messages.Should().HaveCount(1); + messages[0].Role.Should().Be(ChatRole.Assistant); + messages[0].Content.Should().Be("Hi there!"); + } + + [Fact] + public async Task AppendMessageAsync_OrdersChronologically() + { + var conv = await _sut.StartConversationAsync("user-1", "assistant", null); + + await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "first"); + await Task.Delay(5); + await _sut.AppendMessageAsync(conv.Id, ChatRole.Assistant, "second"); + await Task.Delay(5); + await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "third"); + + var messages = await _sut.GetMessagesAsync(conv.Id, "user-1"); + messages.Select(m => m.Content).Should().Equal("first", "second", "third"); + } + + [Fact] + public async Task AppendMessageAsync_GeneratesUniqueIds() + { + var conv = await _sut.StartConversationAsync("user-1", "assistant", null); + + var a = await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "one"); + var b = await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "two"); + + a.Id.Should().NotBe(b.Id); + } + + // ---------- GetMessagesAsync ---------- + + [Fact] + public async Task GetMessagesAsync_EmptyWhenNone() + { + var conv = await _sut.StartConversationAsync("user-1", "assistant", null); + + var messages = await _sut.GetMessagesAsync(conv.Id, "user-1"); + + messages.Should().BeEmpty(); + } + + [Fact] + public async Task GetMessagesAsync_ThrowsForNonOwner() + { + var conv = await _sut.StartConversationAsync("user-1", "assistant", null); + await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "secret"); + + var act = () => _sut.GetMessagesAsync(conv.Id, "user-2"); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetMessagesAsync_IsolatesPerConversation() + { + var a = await _sut.StartConversationAsync("user-1", "assistant", null); + var b = await _sut.StartConversationAsync("user-1", "assistant", null); + await _sut.AppendMessageAsync(a.Id, ChatRole.User, "a1"); + await _sut.AppendMessageAsync(b.Id, ChatRole.User, "b1"); + await _sut.AppendMessageAsync(a.Id, ChatRole.Assistant, "a2"); + + var aMessages = await _sut.GetMessagesAsync(a.Id, "user-1"); + var bMessages = await _sut.GetMessagesAsync(b.Id, "user-1"); + + aMessages.Select(m => m.Content).Should().Equal("a1", "a2"); + bMessages.Select(m => m.Content).Should().Equal("b1"); + } +} diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/Pages/Home.tsx b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/Home.tsx index d14e43df..aa05ee8a 100644 --- a/modules/Dashboard/src/SimpleModule.Dashboard/Pages/Home.tsx +++ b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/Home.tsx @@ -3,24 +3,16 @@ import { Alert, AlertDescription, AlertTitle, - Badge, Button, Card, CardContent, - CardHeader, - CardTitle, Container, PageShell, - Spinner, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, } from '@simplemodule/ui'; -import React from 'react'; import { DashboardKeys } from '@/Locales/keys'; +import { ApiTester } from './components/ApiTester'; +import { TokenTester } from './components/TokenTester'; +import { UserInfoPanel } from './components/UserInfoPanel'; interface HomeProps { isAuthenticated: boolean; @@ -145,385 +137,6 @@ function DashboardView({ ); } -// --- User Info Panel --- - -interface UserInfo { - displayName?: string; - name?: string; - email?: string; - id?: string | { value: string }; - roles?: string | string[]; -} - -function InfoRow({ - label, - value, - monospace, -}: { - label: string; - value: string; - monospace?: boolean; -}) { - return ( -
- {label} - - {value} - -
- ); -} - -function UserInfoPanel() { - const { t } = useTranslation('Dashboard'); - const [userInfo, setUserInfo] = React.useState(null); - const [error, setError] = React.useState(null); - const [loading, setLoading] = React.useState(true); - - React.useEffect(() => { - let cancelled = false; - (async () => { - try { - const res = await fetch('/api/users/me'); - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); - const data = await res.json(); - if (!cancelled) { - setUserInfo(data); - setLoading(false); - } - } catch (e: unknown) { - if (!cancelled) { - setError(e instanceof Error ? e.message : String(e)); - setLoading(false); - } - } - })(); - return () => { - cancelled = true; - }; - }, []); - - return ( - - - {t(DashboardKeys.Home.UserInfoTitle)} - - - {loading && ( -
- {t(DashboardKeys.Home.UserInfoLoading)} - -
- )} - {error && ( - - {t(DashboardKeys.Home.UserInfoError, { error })} - - )} - {userInfo && ( - <> - - - - {userInfo.roles && ( - - )} - - )} -
-
- ); -} - -// --- Token Tester --- - -function generateCodeVerifier(): string { - const arr = new Uint8Array(32); - crypto.getRandomValues(arr); - return btoa(String.fromCharCode(...arr)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - -async function generateCodeChallenge(verifier: string): Promise { - const data = new TextEncoder().encode(verifier); - const hash = await crypto.subtle.digest('SHA-256', data); - return btoa(String.fromCharCode(...new Uint8Array(hash))) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - -interface DecodedClaim { - key: string; - value: string; -} - -function decodeToken(token: string): DecodedClaim[] | null { - try { - const parts = token.split('.'); - const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); - return Object.keys(payload).map((key) => { - const val = payload[key]; - const display = - key === 'exp' || key === 'iat' || key === 'nbf' - ? new Date(val * 1000).toLocaleString() - : typeof val === 'object' - ? JSON.stringify(val) - : String(val); - return { key, value: display }; - }); - } catch { - return null; - } -} - -function TokenTester() { - const { t } = useTranslation('Dashboard'); - const [token, setToken] = React.useState(null); - const [authorizing, setAuthorizing] = React.useState(false); - - const claims = React.useMemo(() => (token ? decodeToken(token) : null), [token]); - - React.useEffect(() => { - const params = new URLSearchParams(window.location.search); - const code = params.get('code'); - const state = params.get('state'); - if (!code) return; - - window.history.replaceState({}, '', '/'); - - const verifier = sessionStorage.getItem('pkce_verifier'); - const savedState = sessionStorage.getItem('pkce_state'); - - if (state !== savedState) { - alert('OAuth state mismatch'); - return; - } - - sessionStorage.removeItem('pkce_verifier'); - sessionStorage.removeItem('pkce_state'); - - (async () => { - try { - const res = await fetch('/connect/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - client_id: 'simplemodule-client', - code, - redirect_uri: `${window.location.origin}/oauth-callback`, - code_verifier: verifier ?? '', - }), - }); - - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); - const data = await res.json(); - setToken(data.access_token); - } catch (e: unknown) { - alert(`Token exchange failed: ${e instanceof Error ? e.message : String(e)}`); - } - })(); - }, []); - - const startOAuthFlow = async () => { - setAuthorizing(true); - - const verifier = generateCodeVerifier(); - const challenge = await generateCodeChallenge(verifier); - const state = crypto.randomUUID(); - - sessionStorage.setItem('pkce_verifier', verifier); - sessionStorage.setItem('pkce_state', state); - - const params = new URLSearchParams({ - response_type: 'code', - client_id: 'simplemodule-client', - redirect_uri: `${window.location.origin}/oauth-callback`, - scope: 'openid profile email', - state, - code_challenge: challenge, - code_challenge_method: 'S256', - }); - - window.location.href = `/connect/authorize?${params.toString()}`; - }; - - return ( - - - {t(DashboardKeys.Home.TokenTesterTitle)} - - -

{t(DashboardKeys.Home.TokenTesterSubtitle)}

-

- {t(DashboardKeys.Home.TokenTesterDescription, { clientId: 'simplemodule-client' })} -

- - {token && ( -
-
- {token} -
- {claims && ( - <> -

- {t(DashboardKeys.Home.TokenTesterDecodedClaims)} -

-
- - - - {t(DashboardKeys.Home.TokenTesterColClaim)} - {t(DashboardKeys.Home.TokenTesterColValue)} - - - - {claims.map((claim) => ( - - {claim.key} - {claim.value} - - ))} - -
-
- - )} -
- )} -
-
- ); -} - -// --- API Tester --- - -const API_ENDPOINTS = ['/api/users/me', '/api/users', '/api/products', '/api/orders']; - -function ApiTester() { - const { t } = useTranslation('Dashboard'); - const [status, setStatus] = React.useState<{ - loading: boolean; - ok?: boolean; - code?: string; - statusText?: string; - url?: string; - error?: string; - } | null>(null); - const [response, setResponse] = React.useState(t(DashboardKeys.Home.ApiTesterDefaultResponse)); - - const getAccessToken = (): string | null => { - const codeBlocks = document.querySelectorAll('.font-mono.text-xs.break-all'); - for (const block of codeBlocks) { - const text = block.textContent || ''; - if (text.includes('.') && text.length > 50) { - return text.trim(); - } - } - return null; - }; - - const callApi = async (url: string) => { - setStatus({ loading: true, url }); - setResponse(''); - - const headers: Record = {}; - const accessToken = getAccessToken(); - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}`; - } - - try { - const res = await fetch(url, { headers }); - const text = await res.text(); - setStatus({ - loading: false, - ok: res.ok, - code: String(res.status), - statusText: res.statusText, - url, - }); - try { - setResponse(JSON.stringify(JSON.parse(text), null, 2)); - } catch { - setResponse(text); - } - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - setStatus({ loading: false, ok: false, code: 'Error', error: msg, url }); - setResponse(msg); - } - }; - - return ( - - - {t(DashboardKeys.Home.ApiTesterTitle)} - - -

{t(DashboardKeys.Home.ApiTesterSubtitle)}

-
- {API_ENDPOINTS.map((url) => ( - - ))} -
-
- {status?.loading && ( - <> - Calling {status.url} - - - )} - {status && !status.loading && ( - <> - {status.code}{' '} - {status.error ? status.error : `${status.statusText} \u2014 ${status.url}`} - - )} -
-
- {response} -
-
-
- ); -} - // --- Landing View --- function LandingView({ isDevelopment }: { isDevelopment: boolean }) { diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/Pages/components/ApiTester.tsx b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/components/ApiTester.tsx new file mode 100644 index 00000000..a80e55c6 --- /dev/null +++ b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/components/ApiTester.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from '@simplemodule/client/use-translation'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Spinner } from '@simplemodule/ui'; +import React from 'react'; +import { DashboardKeys } from '@/Locales/keys'; + +const API_ENDPOINTS = ['/api/users/me', '/api/users', '/api/products', '/api/orders']; + +export function ApiTester() { + const { t } = useTranslation('Dashboard'); + const [status, setStatus] = React.useState<{ + loading: boolean; + ok?: boolean; + code?: string; + statusText?: string; + url?: string; + error?: string; + } | null>(null); + const [response, setResponse] = React.useState(t(DashboardKeys.Home.ApiTesterDefaultResponse)); + + const getAccessToken = (): string | null => { + const codeBlocks = document.querySelectorAll('.font-mono.text-xs.break-all'); + for (const block of codeBlocks) { + const text = block.textContent || ''; + if (text.includes('.') && text.length > 50) { + return text.trim(); + } + } + return null; + }; + + const callApi = async (url: string) => { + setStatus({ loading: true, url }); + setResponse(''); + + const headers: Record = {}; + const accessToken = getAccessToken(); + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + + try { + const res = await fetch(url, { headers }); + const text = await res.text(); + setStatus({ + loading: false, + ok: res.ok, + code: String(res.status), + statusText: res.statusText, + url, + }); + try { + setResponse(JSON.stringify(JSON.parse(text), null, 2)); + } catch { + setResponse(text); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + setStatus({ loading: false, ok: false, code: 'Error', error: msg, url }); + setResponse(msg); + } + }; + + return ( + + + {t(DashboardKeys.Home.ApiTesterTitle)} + + +

{t(DashboardKeys.Home.ApiTesterSubtitle)}

+
+ {API_ENDPOINTS.map((url) => ( + + ))} +
+
+ {status?.loading && ( + <> + Calling {status.url} + + + )} + {status && !status.loading && ( + <> + {status.code}{' '} + {status.error ? status.error : `${status.statusText} \u2014 ${status.url}`} + + )} +
+
+ {response} +
+
+
+ ); +} diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/Pages/components/TokenTester.tsx b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/components/TokenTester.tsx new file mode 100644 index 00000000..45a7fc7b --- /dev/null +++ b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/components/TokenTester.tsx @@ -0,0 +1,188 @@ +import { useTranslation } from '@simplemodule/client/use-translation'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; +import React from 'react'; +import { DashboardKeys } from '@/Locales/keys'; + +function generateCodeVerifier(): string { + const arr = new Uint8Array(32); + crypto.getRandomValues(arr); + return btoa(String.fromCharCode(...arr)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +async function generateCodeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + return btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +interface DecodedClaim { + key: string; + value: string; +} + +function decodeToken(token: string): DecodedClaim[] | null { + try { + const parts = token.split('.'); + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); + return Object.keys(payload).map((key) => { + const val = payload[key]; + const display = + key === 'exp' || key === 'iat' || key === 'nbf' + ? new Date(val * 1000).toLocaleString() + : typeof val === 'object' + ? JSON.stringify(val) + : String(val); + return { key, value: display }; + }); + } catch { + return null; + } +} + +export function TokenTester() { + const { t } = useTranslation('Dashboard'); + const [token, setToken] = React.useState(null); + const [authorizing, setAuthorizing] = React.useState(false); + + const claims = React.useMemo(() => (token ? decodeToken(token) : null), [token]); + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + if (!code) return; + + window.history.replaceState({}, '', '/'); + + const verifier = sessionStorage.getItem('pkce_verifier'); + const savedState = sessionStorage.getItem('pkce_state'); + + if (state !== savedState) { + alert('OAuth state mismatch'); + return; + } + + sessionStorage.removeItem('pkce_verifier'); + sessionStorage.removeItem('pkce_state'); + + (async () => { + try { + const res = await fetch('/connect/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'simplemodule-client', + code, + redirect_uri: `${window.location.origin}/oauth-callback`, + code_verifier: verifier ?? '', + }), + }); + + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + const data = await res.json(); + setToken(data.access_token); + } catch (e: unknown) { + alert(`Token exchange failed: ${e instanceof Error ? e.message : String(e)}`); + } + })(); + }, []); + + const startOAuthFlow = async () => { + setAuthorizing(true); + + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const state = crypto.randomUUID(); + + sessionStorage.setItem('pkce_verifier', verifier); + sessionStorage.setItem('pkce_state', state); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: 'simplemodule-client', + redirect_uri: `${window.location.origin}/oauth-callback`, + scope: 'openid profile email', + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + window.location.href = `/connect/authorize?${params.toString()}`; + }; + + return ( + + + {t(DashboardKeys.Home.TokenTesterTitle)} + + +

{t(DashboardKeys.Home.TokenTesterSubtitle)}

+

+ {t(DashboardKeys.Home.TokenTesterDescription, { clientId: 'simplemodule-client' })} +

+ + {token && ( +
+
+ {token} +
+ {claims && ( + <> +

+ {t(DashboardKeys.Home.TokenTesterDecodedClaims)} +

+
+ + + + {t(DashboardKeys.Home.TokenTesterColClaim)} + {t(DashboardKeys.Home.TokenTesterColValue)} + + + + {claims.map((claim) => ( + + {claim.key} + {claim.value} + + ))} + +
+
+ + )} +
+ )} +
+
+ ); +} diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/Pages/components/UserInfoPanel.tsx b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/components/UserInfoPanel.tsx new file mode 100644 index 00000000..8690a55e --- /dev/null +++ b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/components/UserInfoPanel.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from '@simplemodule/client/use-translation'; +import { Card, CardContent, CardHeader, CardTitle, Spinner } from '@simplemodule/ui'; +import React from 'react'; +import { DashboardKeys } from '@/Locales/keys'; + +interface UserInfo { + displayName?: string; + name?: string; + email?: string; + id?: string | { value: string }; + roles?: string | string[]; +} + +function InfoRow({ + label, + value, + monospace, +}: { + label: string; + value: string; + monospace?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} + +export function UserInfoPanel() { + const { t } = useTranslation('Dashboard'); + const [userInfo, setUserInfo] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch('/api/users/me'); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + const data = await res.json(); + if (!cancelled) { + setUserInfo(data); + setLoading(false); + } + } catch (e: unknown) { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)); + setLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return ( + + + {t(DashboardKeys.Home.UserInfoTitle)} + + + {loading && ( +
+ {t(DashboardKeys.Home.UserInfoLoading)} + +
+ )} + {error && ( + + {t(DashboardKeys.Home.UserInfoError, { error })} + + )} + {userInfo && ( + <> + + + + {userInfo.roles && ( + + )} + + )} +
+
+ ); +} diff --git a/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.Features.cs b/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.Features.cs new file mode 100644 index 00000000..a476d5b6 --- /dev/null +++ b/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.Features.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core.Settings; +using SimpleModule.Datasets.Contracts; + +namespace SimpleModule.Datasets; + +public sealed partial class DatasetsContractsService +{ + public async Task GetFeaturesGeoJsonAsync( + DatasetId id, + BoundingBoxDto? bbox = null, + int? limit = null, + CancellationToken ct = default + ) + { + var row = await db.Datasets.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id, ct); + if (row is null) + { + throw new InvalidOperationException($"Dataset {id.Value} not found"); + } + if (!row.Format.IsVector() || row.NormalizedPath is null) + { + throw new InvalidOperationException( + "Feature query is only supported for vector datasets that have been processed." + ); + } + + await using var stream = await storage.GetAsync(row.NormalizedPath, ct); + if (stream is null) + { + throw new InvalidOperationException("Normalized GeoJSON not found in storage."); + } + + var effectiveLimit = + limit + ?? await settings.GetSettingAsync( + DatasetsConstants.SettingKeys.FeatureQueryLimit, + SettingScope.Application + ) + ?? 1000; + + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct); + var root = doc.RootElement; + if ( + !root.TryGetProperty("features", out var features) + || features.ValueKind != JsonValueKind.Array + ) + { + return """{"type":"FeatureCollection","features":[]}"""; + } + + using var ms = new MemoryStream(); + await using (var writer = new Utf8JsonWriter(ms)) + { + writer.WriteStartObject(); + writer.WriteString("type", "FeatureCollection"); + writer.WriteStartArray("features"); + var count = 0; + foreach (var feature in features.EnumerateArray()) + { + if (count >= effectiveLimit) + { + break; + } + if (bbox is not null && !FeatureIntersectsBbox(feature, bbox)) + { + continue; + } + feature.WriteTo(writer); + count++; + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + return System.Text.Encoding.UTF8.GetString(ms.GetBuffer().AsSpan(0, (int)ms.Length)); + } + + private static bool FeatureIntersectsBbox(JsonElement feature, BoundingBoxDto bbox) + { + if ( + !feature.TryGetProperty("geometry", out var geometry) + || geometry.ValueKind != JsonValueKind.Object + || !geometry.TryGetProperty("coordinates", out var coords) + ) + { + return false; + } + + double minX = double.PositiveInfinity, + minY = double.PositiveInfinity; + double maxX = double.NegativeInfinity, + maxY = double.NegativeInfinity; + Processing.GeoJsonBboxWalker.Expand(coords, ref minX, ref minY, ref maxX, ref maxY); + if (double.IsInfinity(minX)) + { + return false; + } + return !(minX > bbox.MaxX || maxX < bbox.MinX || minY > bbox.MaxY || maxY < bbox.MinY); + } +} diff --git a/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.cs b/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.cs index 0c36bb1e..7a61d029 100644 --- a/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.cs +++ b/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.cs @@ -155,75 +155,6 @@ await jobs.EnqueueAsync( return await storage.GetAsync(derivative.StoragePath, ct); } - public async Task GetFeaturesGeoJsonAsync( - DatasetId id, - BoundingBoxDto? bbox = null, - int? limit = null, - CancellationToken ct = default - ) - { - var row = await db.Datasets.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id, ct); - if (row is null) - { - throw new InvalidOperationException($"Dataset {id.Value} not found"); - } - if (!row.Format.IsVector() || row.NormalizedPath is null) - { - throw new InvalidOperationException( - "Feature query is only supported for vector datasets that have been processed." - ); - } - - await using var stream = await storage.GetAsync(row.NormalizedPath, ct); - if (stream is null) - { - throw new InvalidOperationException("Normalized GeoJSON not found in storage."); - } - - var effectiveLimit = - limit - ?? await settings.GetSettingAsync( - DatasetsConstants.SettingKeys.FeatureQueryLimit, - SettingScope.Application - ) - ?? 1000; - - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct); - var root = doc.RootElement; - if ( - !root.TryGetProperty("features", out var features) - || features.ValueKind != JsonValueKind.Array - ) - { - return """{"type":"FeatureCollection","features":[]}"""; - } - - using var ms = new MemoryStream(); - await using (var writer = new Utf8JsonWriter(ms)) - { - writer.WriteStartObject(); - writer.WriteString("type", "FeatureCollection"); - writer.WriteStartArray("features"); - var count = 0; - foreach (var feature in features.EnumerateArray()) - { - if (count >= effectiveLimit) - { - break; - } - if (bbox is not null && !FeatureIntersectsBbox(feature, bbox)) - { - continue; - } - feature.WriteTo(writer); - count++; - } - writer.WriteEndArray(); - writer.WriteEndObject(); - } - return System.Text.Encoding.UTF8.GetString(ms.GetBuffer().AsSpan(0, (int)ms.Length)); - } - public async Task> FindByBoundingBoxAsync( BoundingBoxDto bbox, CancellationToken ct = default @@ -306,29 +237,6 @@ internal static DatasetDto ToDto(Dataset row) => private static DatasetMetadata? DeserializeMetadata(string? json) => string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize(json); - private static bool FeatureIntersectsBbox(JsonElement feature, BoundingBoxDto bbox) - { - if ( - !feature.TryGetProperty("geometry", out var geometry) - || geometry.ValueKind != JsonValueKind.Object - || !geometry.TryGetProperty("coordinates", out var coords) - ) - { - return false; - } - - double minX = double.PositiveInfinity, - minY = double.PositiveInfinity; - double maxX = double.NegativeInfinity, - maxY = double.NegativeInfinity; - Processing.GeoJsonBboxWalker.Expand(coords, ref minX, ref minY, ref maxX, ref maxY); - if (double.IsInfinity(minX)) - { - return false; - } - return !(minX > bbox.MaxX || maxX < bbox.MinX || minY > bbox.MaxY || maxY < bbox.MinY); - } - [LoggerMessage(Level = LogLevel.Information, Message = "Dataset created: {Id} ({FileName})")] private static partial void LogDatasetCreated(ILogger logger, Guid id, string fileName); diff --git a/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs b/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs new file mode 100644 index 00000000..6c88c2ec --- /dev/null +++ b/modules/Email/src/SimpleModule.Email/EmailService.Templates.cs @@ -0,0 +1,151 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SimpleModule.Core; +using SimpleModule.Email.Contracts; +using SimpleModule.Email.Contracts.Events; + +namespace SimpleModule.Email; + +public partial class EmailService +{ + public async Task> QueryTemplatesAsync( + QueryEmailTemplatesRequest request + ) + { + var query = db.EmailTemplates.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + query = query.Where(t => + t.Name.Contains(request.Search) || t.Slug.Contains(request.Search) + ); + + var totalCount = await query.CountAsync(); + var page = request.EffectivePage; + var pageSize = request.EffectivePageSize; + + var items = await query + .OrderBy(t => t.Name) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + }; + } + + public async Task GetTemplateByIdAsync(EmailTemplateId id) => + await db.EmailTemplates.FindAsync(id); + + public async Task GetTemplateBySlugAsync(string slug) => + await db.EmailTemplates.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug); + + public async Task CreateTemplateAsync(CreateEmailTemplateRequest request) + { + var slugExists = await db.EmailTemplates.AnyAsync(t => t.Slug == request.Slug); + if (slugExists) + { + throw new Core.Exceptions.ConflictException( + $"A template with slug '{request.Slug}' already exists." + ); + } + + var template = new EmailTemplate + { + Name = request.Name, + Slug = request.Slug, + Subject = request.Subject, + Body = request.Body, + IsHtml = request.IsHtml, + DefaultReplyTo = request.DefaultReplyTo, + }; + + db.EmailTemplates.Add(template); + await db.SaveChangesAsync(); + + LogTemplateCreated(logger, template.Id, template.Name); + eventBus.PublishInBackground( + new EmailTemplateCreatedEvent(template.Id, template.Name, template.Slug) + ); + + return template; + } + + public async Task UpdateTemplateAsync( + EmailTemplateId id, + UpdateEmailTemplateRequest request + ) + { + var template = + await db.EmailTemplates.FindAsync(id) + ?? throw new Core.Exceptions.NotFoundException("EmailTemplate", id); + + var changedFields = new List(); + if (template.Name != request.Name) + changedFields.Add("Name"); + if (template.Subject != request.Subject) + changedFields.Add("Subject"); + if (template.Body != request.Body) + changedFields.Add("Body"); + if (template.IsHtml != request.IsHtml) + changedFields.Add("IsHtml"); + if (template.DefaultReplyTo != request.DefaultReplyTo) + changedFields.Add("DefaultReplyTo"); + + template.Name = request.Name; + template.Subject = request.Subject; + template.Body = request.Body; + template.IsHtml = request.IsHtml; + template.DefaultReplyTo = request.DefaultReplyTo; + + await db.SaveChangesAsync(); + + LogTemplateUpdated(logger, template.Id, template.Name); + eventBus.PublishInBackground( + new EmailTemplateUpdatedEvent(template.Id, template.Name, changedFields) + ); + + return template; + } + + public async Task DeleteTemplateAsync(EmailTemplateId id) + { + var template = + await db.EmailTemplates.FindAsync(id) + ?? throw new Core.Exceptions.NotFoundException("EmailTemplate", id); + + var templateName = template.Name; + db.EmailTemplates.Remove(template); + await db.SaveChangesAsync(); + + LogTemplateDeleted(logger, id); + eventBus.PublishInBackground(new EmailTemplateDeletedEvent(id, templateName)); + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Email template {TemplateId} created: {TemplateName}" + )] + private static partial void LogTemplateCreated( + ILogger logger, + EmailTemplateId templateId, + string templateName + ); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Email template {TemplateId} updated: {TemplateName}" + )] + private static partial void LogTemplateUpdated( + ILogger logger, + EmailTemplateId templateId, + string templateName + ); + + [LoggerMessage(Level = LogLevel.Information, Message = "Email template {TemplateId} deleted")] + private static partial void LogTemplateDeleted(ILogger logger, EmailTemplateId templateId); +} diff --git a/modules/Email/src/SimpleModule.Email/EmailService.cs b/modules/Email/src/SimpleModule.Email/EmailService.cs index 8c9e9a0d..ff221966 100644 --- a/modules/Email/src/SimpleModule.Email/EmailService.cs +++ b/modules/Email/src/SimpleModule.Email/EmailService.cs @@ -142,124 +142,6 @@ QueryEmailMessagesRequest request public async Task GetMessageByIdAsync(EmailMessageId id) => await db.EmailMessages.FindAsync(id); - public async Task> QueryTemplatesAsync( - QueryEmailTemplatesRequest request - ) - { - var query = db.EmailTemplates.AsNoTracking().AsQueryable(); - - if (!string.IsNullOrWhiteSpace(request.Search)) - query = query.Where(t => - t.Name.Contains(request.Search) || t.Slug.Contains(request.Search) - ); - - var totalCount = await query.CountAsync(); - var page = request.EffectivePage; - var pageSize = request.EffectivePageSize; - - var items = await query - .OrderBy(t => t.Name) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); - - return new PagedResult - { - Items = items, - TotalCount = totalCount, - Page = page, - PageSize = pageSize, - }; - } - - public async Task GetTemplateByIdAsync(EmailTemplateId id) => - await db.EmailTemplates.FindAsync(id); - - public async Task GetTemplateBySlugAsync(string slug) => - await db.EmailTemplates.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug); - - public async Task CreateTemplateAsync(CreateEmailTemplateRequest request) - { - var slugExists = await db.EmailTemplates.AnyAsync(t => t.Slug == request.Slug); - if (slugExists) - { - throw new Core.Exceptions.ConflictException( - $"A template with slug '{request.Slug}' already exists." - ); - } - - var template = new EmailTemplate - { - Name = request.Name, - Slug = request.Slug, - Subject = request.Subject, - Body = request.Body, - IsHtml = request.IsHtml, - DefaultReplyTo = request.DefaultReplyTo, - }; - - db.EmailTemplates.Add(template); - await db.SaveChangesAsync(); - - LogTemplateCreated(logger, template.Id, template.Name); - eventBus.PublishInBackground( - new EmailTemplateCreatedEvent(template.Id, template.Name, template.Slug) - ); - - return template; - } - - public async Task UpdateTemplateAsync( - EmailTemplateId id, - UpdateEmailTemplateRequest request - ) - { - var template = - await db.EmailTemplates.FindAsync(id) - ?? throw new Core.Exceptions.NotFoundException("EmailTemplate", id); - - var changedFields = new List(); - if (template.Name != request.Name) - changedFields.Add("Name"); - if (template.Subject != request.Subject) - changedFields.Add("Subject"); - if (template.Body != request.Body) - changedFields.Add("Body"); - if (template.IsHtml != request.IsHtml) - changedFields.Add("IsHtml"); - if (template.DefaultReplyTo != request.DefaultReplyTo) - changedFields.Add("DefaultReplyTo"); - - template.Name = request.Name; - template.Subject = request.Subject; - template.Body = request.Body; - template.IsHtml = request.IsHtml; - template.DefaultReplyTo = request.DefaultReplyTo; - - await db.SaveChangesAsync(); - - LogTemplateUpdated(logger, template.Id, template.Name); - eventBus.PublishInBackground( - new EmailTemplateUpdatedEvent(template.Id, template.Name, changedFields) - ); - - return template; - } - - public async Task DeleteTemplateAsync(EmailTemplateId id) - { - var template = - await db.EmailTemplates.FindAsync(id) - ?? throw new Core.Exceptions.NotFoundException("EmailTemplate", id); - - var templateName = template.Name; - db.EmailTemplates.Remove(template); - await db.SaveChangesAsync(); - - LogTemplateDeleted(logger, id); - eventBus.PublishInBackground(new EmailTemplateDeletedEvent(id, templateName)); - } - public async Task GetEmailStatsAsync() { var now = DateTimeOffset.UtcNow; @@ -334,27 +216,4 @@ public async Task GetEmailStatsAsync() [LoggerMessage(Level = LogLevel.Information, Message = "Email {MessageId} queued for {To}")] private static partial void LogEmailQueued(ILogger logger, EmailMessageId messageId, string to); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Email template {TemplateId} created: {TemplateName}" - )] - private static partial void LogTemplateCreated( - ILogger logger, - EmailTemplateId templateId, - string templateName - ); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Email template {TemplateId} updated: {TemplateName}" - )] - private static partial void LogTemplateUpdated( - ILogger logger, - EmailTemplateId templateId, - string templateName - ); - - [LoggerMessage(Level = LogLevel.Information, Message = "Email template {TemplateId} deleted")] - private static partial void LogTemplateDeleted(ILogger logger, EmailTemplateId templateId); } diff --git a/modules/Email/src/SimpleModule.Email/Pages/History.tsx b/modules/Email/src/SimpleModule.Email/Pages/History.tsx index f0811b36..d67148da 100644 --- a/modules/Email/src/SimpleModule.Email/Pages/History.tsx +++ b/modules/Email/src/SimpleModule.Email/Pages/History.tsx @@ -5,13 +5,7 @@ import { Button, Card, CardContent, - Input, PageShell, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, Table, TableBody, TableCell, @@ -22,6 +16,8 @@ import { import { type FormEvent, useState } from 'react'; import { EmailKeys } from '../Locales/keys'; import type { EmailMessage } from '../types'; +import { HistoryFilters } from './components/HistoryFilters'; +import { HistoryPagination } from './components/HistoryPagination'; type EmailStatus = 'Queued' | 'Sending' | 'Sent' | 'Failed' | 'Retrying'; @@ -57,8 +53,6 @@ interface Props { }; } -const STATUS_OPTIONS: EmailStatus[] = ['Queued', 'Sending', 'Sent', 'Failed', 'Retrying']; - function buildFilterParams(f: Props['filters'], page?: number): Record { const params: Record = {}; if (f.status) params.status = f.status; @@ -70,36 +64,6 @@ function buildFilterParams(f: Props['filters'], page?: number): Record