diff --git a/src/Trax.Dashboard/Components/Dialogs/QueueTrainDialog.razor.cs b/src/Trax.Dashboard/Components/Dialogs/QueueTrainDialog.razor.cs index 8d662ee..cb38d81 100644 --- a/src/Trax.Dashboard/Components/Dialogs/QueueTrainDialog.razor.cs +++ b/src/Trax.Dashboard/Components/Dialogs/QueueTrainDialog.razor.cs @@ -4,12 +4,8 @@ using System.Text.RegularExpressions; using Microsoft.AspNetCore.Components; using Radzen; -using Trax.Effect.Configuration.TraxEffectConfiguration; -using Trax.Effect.Data.Services.IDataContextFactory; -using Trax.Effect.Models.WorkQueue; -using Trax.Effect.Models.WorkQueue.DTOs; -using Trax.Effect.Utils; using Trax.Mediator.Services.TrainDiscovery; +using Trax.Scheduler.Services.Operations; namespace Trax.Dashboard.Components.Dialogs; @@ -18,7 +14,7 @@ public partial class QueueTrainDialog : IDisposable private readonly CancellationTokenSource _cts = new(); [Inject] - private IDataContextProviderFactory DataContextFactory { get; set; } = default!; + private IOperationsService OperationsService { get; set; } = default!; [Inject] private NavigationManager Navigation { get; set; } = default!; @@ -70,48 +66,29 @@ private async Task QueueTrain() try { - var input = - _selectedTab == 0 - ? BuildInputFromForm() - : JsonSerializer.Deserialize( - _jsonInput, - Registration.InputType, - TraxEffectConfiguration.StaticSystemJsonSerializerOptions - ); - - if (input is null) + // The form-builder tab produces strongly typed values; the JSON tab is already + // a JSON string. Either way we end up with a JSON string to hand the shared + // IOperationsService, which performs the actual deserialization + validation. + string? inputJson = _selectedTab == 0 ? BuildInputJsonFromForm() : _jsonInput; + + var result = await OperationsService.QueueTrainAsync( + new QueueTrainInput( + TrainName: Registration.ServiceType.FullName!, + InputJson: inputJson, + Priority: _priority + ), + _cts.Token + ); + + if (!result.Success) { - _error = - $"Deserialization returned null. Ensure the input matches {Registration.InputTypeName}."; + _error = result.Message; return; } - var serializedInput = JsonSerializer.Serialize( - input, - Registration.InputType, - TraxJsonSerializationOptions.ManifestProperties - ); - - var entry = WorkQueue.Create( - new CreateWorkQueue - { - TrainName = Registration.ServiceType.FullName!, - Input = serializedInput, - InputTypeName = Registration.InputType.FullName, - Priority = _priority, - } - ); - - using var dataContext = await DataContextFactory.CreateDbContextAsync(_cts.Token); - await dataContext.Track(entry); - await dataContext.SaveChanges(_cts.Token); - DialogService.Close(); - Navigation.NavigateTo($"trax/data/work-queue/{entry.Id}"); - } - catch (JsonException je) - { - _error = $"Invalid JSON: {je.Message}"; + if (result.Id is { } id) + Navigation.NavigateTo($"trax/data/work-queue/{id}"); } catch (Exception ex) { @@ -123,7 +100,7 @@ private async Task QueueTrain() } } - private object? BuildInputFromForm() + private string BuildInputJsonFromForm() { var jsonObj = new JsonObject(); @@ -133,11 +110,7 @@ private async Task QueueTrain() jsonObj[prop.Name] = ToJsonNode(value, prop.PropertyType); } - return JsonSerializer.Deserialize( - jsonObj.ToJsonString(), - Registration.InputType, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true } - ); + return jsonObj.ToJsonString(); } private static JsonNode? ToJsonNode(object? value, Type targetType) diff --git a/src/Trax.Dashboard/Components/Pages/Data/ManifestGroupDetailPage.razor.cs b/src/Trax.Dashboard/Components/Pages/Data/ManifestGroupDetailPage.razor.cs index e2db49c..6039b77 100644 --- a/src/Trax.Dashboard/Components/Pages/Data/ManifestGroupDetailPage.razor.cs +++ b/src/Trax.Dashboard/Components/Pages/Data/ManifestGroupDetailPage.razor.cs @@ -11,6 +11,7 @@ using Trax.Effect.Models.Manifest; using Trax.Effect.Models.ManifestGroup; using Trax.Effect.Models.Metadata; +using Trax.Scheduler.Services.Operations; using Trax.Scheduler.Services.TraxScheduler; using static Trax.Dashboard.Utilities.DashboardFormatters; @@ -30,6 +31,9 @@ public partial class ManifestGroupDetailPage [Inject] private ITraxScheduler TraxScheduler { get; set; } = default!; + [Inject] + private IOperationsService OperationsService { get; set; } = default!; + [Inject] private IServiceProvider ServiceProvider { get; set; } = default!; @@ -207,93 +211,37 @@ private async Task LoadDependencyGraph( CancellationToken cancellationToken ) { - var currentManifestIdsQuery = context - .Manifests.Where(m => m.ManifestGroupId == ManifestGroupId) - .Select(m => m.Id); - - if (!await currentManifestIdsQuery.AnyAsync(cancellationToken)) - { - _dagLayout = null; - return; - } + // Source the graph from the shared OperationsService so the dashboard's DAG and + // the GraphQL `operations.manifestGroups.graph` query produce identical results. + // The IDataContext parameter is retained for signature compatibility but the + // service opens its own context. + _ = context; + + var graph = await OperationsService.GetManifestGroupDependencyGraphAsync( + ManifestGroupId, + cancellationToken + ); - // Upstream: groups containing manifests that our manifests depend on - var upstreamGroupIds = await context - .Manifests.AsNoTracking() - .Where(m => m.ManifestGroupId == ManifestGroupId && m.DependsOnManifestId != null) - .Join( - context.Manifests.AsNoTracking(), - dependent => dependent.DependsOnManifestId, - parent => (long?)parent.Id, - (dependent, parent) => parent.ManifestGroupId - ) - .Where(parentGroupId => parentGroupId != ManifestGroupId) - .Distinct() - .ToListAsync(cancellationToken); - - // Downstream: groups containing manifests that depend on our manifests - var downstreamGroupIds = await context - .Manifests.AsNoTracking() - .Where(m => - m.DependsOnManifestId != null - && currentManifestIdsQuery.Contains(m.DependsOnManifestId.Value) - && m.ManifestGroupId != ManifestGroupId - ) - .Select(m => m.ManifestGroupId) - .Distinct() - .ToListAsync(cancellationToken); - - var neighborGroupIds = upstreamGroupIds.Union(downstreamGroupIds).ToHashSet(); - - if (neighborGroupIds.Count == 0) + // Single-node graphs (focal group only, no cross-group dependencies) collapse to + // null here so the UI hides the DAG section entirely, matching the previous + // dashboard behaviour where an isolated group rendered nothing. + if (graph is null || graph.Edges.Count == 0) { _dagLayout = null; return; } - var allRelevantGroupIds = neighborGroupIds.Append(ManifestGroupId).ToList(); - - var neighborGroups = await context - .ManifestGroups.AsNoTracking() - .Where(g => allRelevantGroupIds.Contains(g.Id)) - .Select(g => new { g.Id, g.Name }) - .ToListAsync(cancellationToken); - - var dagNodes = neighborGroups - .Select(g => new DagNode + var dagNodes = graph + .Nodes.Select(n => new DagNode { - Id = g.Id, - Label = g.Name, - IsHighlighted = g.Id == ManifestGroupId, + Id = n.Id, + Label = n.Name, + IsHighlighted = n.IsHighlighted, }) .ToList(); - // Edges between all relevant groups - var crossGroupEdges = await context - .Manifests.AsNoTracking() - .Where(m => - m.DependsOnManifestId != null && allRelevantGroupIds.Contains(m.ManifestGroupId) - ) - .Join( - context.Manifests.AsNoTracking(), - dependent => dependent.DependsOnManifestId, - parent => (long?)parent.Id, - (dependent, parent) => - new - { - ParentGroupId = parent.ManifestGroupId, - DependentGroupId = dependent.ManifestGroupId, - } - ) - .Where(e => - e.ParentGroupId != e.DependentGroupId - && allRelevantGroupIds.Contains(e.ParentGroupId) - ) - .Distinct() - .ToListAsync(cancellationToken); - - var dagEdges = crossGroupEdges - .Select(e => new DagEdge { FromId = e.ParentGroupId, ToId = e.DependentGroupId }) + var dagEdges = graph + .Edges.Select(e => new DagEdge { FromId = e.FromId, ToId = e.ToId }) .ToList(); _dagLayout = DagLayoutEngine.ComputeLayout(dagNodes, dagEdges); @@ -318,27 +266,46 @@ private async Task SaveSettings() try { - using var context = await DataContextFactory.CreateDbContextAsync(DisposalToken); - - var entity = await context.ManifestGroups.FindAsync(_group.Id); - if (entity is null) - return; + // Translate the dirty in-memory edits into a patch input for the shared + // service. MaxActiveJobs needs the explicit Clear flag because int? can't + // distinguish "unset" from "set to null" in the patch record. + var maxActiveJobsChanged = _group.MaxActiveJobs != _savedMaxActiveJobs; + var input = new UpdateManifestGroupInput( + MaxActiveJobs: maxActiveJobsChanged ? _group.MaxActiveJobs : null, + ClearMaxActiveJobs: maxActiveJobsChanged && _group.MaxActiveJobs is null, + Priority: _group.Priority != _savedPriority ? _group.Priority : null, + IsEnabled: _group.IsEnabled != _savedIsEnabled ? _group.IsEnabled : null + ); - entity.MaxActiveJobs = _group.MaxActiveJobs; - entity.Priority = _group.Priority; - entity.IsEnabled = _group.IsEnabled; - entity.UpdatedAt = DateTime.UtcNow; + var result = await OperationsService.UpdateManifestGroupAsync( + _group.Id, + input, + DisposalToken + ); - await context.SaveChanges(DisposalToken); + if (!result.Success) + { + NotificationService.Notify( + new NotificationMessage + { + Severity = NotificationSeverity.Error, + Summary = "Save Failed", + Detail = result.Message ?? "Update failed.", + Duration = 6000, + } + ); + return; + } - SnapshotSettings(); + // Reload to pick up the bumped UpdatedAt and confirm persistence. + await LoadDataAsync(DisposalToken); NotificationService.Notify( new NotificationMessage { Severity = NotificationSeverity.Success, Summary = "Settings Saved", - Detail = $"Group \"{_group.Name}\" settings updated.", + Detail = $"Group \"{_group?.Name}\" settings updated.", Duration = 4000, } ); diff --git a/src/Trax.Dashboard/Components/Pages/Data/WorkQueueDetailPage.razor.cs b/src/Trax.Dashboard/Components/Pages/Data/WorkQueueDetailPage.razor.cs index edbb83d..2ed36b9 100644 --- a/src/Trax.Dashboard/Components/Pages/Data/WorkQueueDetailPage.razor.cs +++ b/src/Trax.Dashboard/Components/Pages/Data/WorkQueueDetailPage.razor.cs @@ -3,8 +3,8 @@ using Radzen; using Trax.Dashboard.Components.Shared; using Trax.Effect.Data.Services.IDataContextFactory; -using Trax.Effect.Enums; using Trax.Effect.Models.WorkQueue; +using Trax.Scheduler.Services.Operations; using static Trax.Dashboard.Utilities.DashboardFormatters; namespace Trax.Dashboard.Components.Pages.Data; @@ -14,6 +14,9 @@ public partial class WorkQueueDetailPage [Inject] private IDataContextProviderFactory DataContextFactory { get; set; } = default!; + [Inject] + private IOperationsService OperationsService { get; set; } = default!; + [Inject] private NavigationManager Navigation { get; set; } = default!; @@ -47,30 +50,24 @@ private async Task CancelEntry() try { - using var context = await DataContextFactory.CreateDbContextAsync(DisposalToken); - var entry = await context.WorkQueues.FirstOrDefaultAsync(q => q.Id == WorkQueueId); - - if (entry is null) - { - _error = "Work queue entry not found."; - return; - } + var result = await OperationsService.CancelWorkQueueEntryAsync( + WorkQueueId, + DisposalToken + ); - if (entry.Status != WorkQueueStatus.Queued) + if (!result.Success) { - _error = $"Cannot cancel entry with status '{entry.Status}'."; + _error = result.Message; return; } - entry.Status = WorkQueueStatus.Cancelled; - await context.SaveChanges(DisposalToken); - - _entry = entry; + // Reload so the UI reflects the new status without a full page navigation. + await LoadDataAsync(DisposalToken); NotificationService.Notify( NotificationSeverity.Success, "Entry Cancelled", - $"Work queue entry {entry.Id} has been cancelled.", + $"Work queue entry {WorkQueueId} has been cancelled.", duration: 4000 ); } diff --git a/src/Trax.Dashboard/Components/Pages/Index.razor.cs b/src/Trax.Dashboard/Components/Pages/Index.razor.cs index 1d8562a..56e5cd2 100644 --- a/src/Trax.Dashboard/Components/Pages/Index.razor.cs +++ b/src/Trax.Dashboard/Components/Pages/Index.razor.cs @@ -1,13 +1,10 @@ using System.Diagnostics; using Microsoft.AspNetCore.Components; -using Microsoft.EntityFrameworkCore; using Radzen; using Trax.Dashboard.Components.Shared; using Trax.Dashboard.Models; -using Trax.Dashboard.Utilities; -using Trax.Effect.Data.Services.IDataContextFactory; using Trax.Effect.Enums; -using Trax.Effect.Models.Metadata; +using Trax.Scheduler.Services.Operations; using static Trax.Dashboard.Utilities.DashboardFormatters; namespace Trax.Dashboard.Components.Pages; @@ -18,7 +15,7 @@ public partial class Index private const string TimeRange24H = "24h"; [Inject] - private IDataContextProviderFactory DataContextFactory { get; set; } = default!; + private IOperationsService OperationsService { get; set; } = default!; [Inject] private IServiceProvider ServiceProvider { get; set; } = default!; @@ -36,11 +33,12 @@ public partial class Index private List ExecutionsOverTime => _selectedTimeRange == TimeRange1H ? _minuteData : _hourlyData; private string _selectedTimeRange = TimeRange24H; - private List _topFailures = []; + private List _topFailures = []; private List _avgDurations = []; // Throughput sparkline (7d) — one series per top train + "Other" - private List _throughputSeries = []; + private static readonly string[] SeriesColors = { "#2E7D32", "#1565C0", "#F9A825", "#78909C" }; + private List _throughputSeries = []; // Server health private double _cpuPercent; @@ -56,322 +54,107 @@ protected override async Task LoadDataAsync(CancellationToken cancellationToken) CollectServerHealthMetrics(); - using var context = await DataContextFactory.CreateDbContextAsync(cancellationToken); - var now = DateTime.UtcNow; - var todayStart = now.Date; - + // The shared OperationsService is the single source of truth for these metrics; + // the GraphQL operations.metrics.dashboard query reads the exact same data. var hideAdmin = DashboardSettings.HideAdminTrains; - var adminNames = DashboardSettings.AdminTrainNames; - - // Summary cards — single GroupBy for today's state counts - var todayQuery = context.Metadatas.AsNoTracking().Where(m => m.StartTime >= todayStart); - - if (hideAdmin) - todayQuery = todayQuery.ExcludeAdmin(adminNames); - - var todayStateCounts = await todayQuery - .GroupBy(m => m.TrainState) - .Select(g => new { State = g.Key, Count = g.Count() }) - .ToListAsync(cancellationToken); - - int CountForState(TrainState s) => - todayStateCounts.FirstOrDefault(x => x.State == s)?.Count ?? 0; - - _executionsToday = todayStateCounts.Sum(x => x.Count); - - var completed = CountForState(TrainState.Completed); - var terminal = completed + CountForState(TrainState.Failed); - _successRate = terminal > 0 ? Math.Round(100.0 * completed / terminal, 1) : 0; - - var runningQuery = context - .Metadatas.AsNoTracking() - .Where(m => m.TrainState == TrainState.InProgress); - - if (hideAdmin) - runningQuery = runningQuery.ExcludeAdmin(adminNames); - - _currentlyRunning = await runningQuery.CountAsync(cancellationToken); - - _unresolvedDeadLetters = await context - .DeadLetters.AsNoTracking() - .CountAsync(d => d.Status == DeadLetterStatus.AwaitingIntervention, cancellationToken); - - // Executions over time (last 24h, grouped by hour) - var last24h = now.AddHours(-24); - var recentQuery = context.Metadatas.AsNoTracking().Where(m => m.StartTime >= last24h); - - if (hideAdmin) - recentQuery = recentQuery.ExcludeAdmin(adminNames); - - var hourlyStats = await recentQuery - .GroupBy(m => new - { - m.StartTime.Date, - m.StartTime.Hour, - m.TrainState, - }) - .Select(g => new - { - g.Key.Date, - g.Key.Hour, - g.Key.TrainState, - Count = g.Count(), - }) - .ToListAsync(cancellationToken); - - _hourlyData = Enumerable - .Range(0, 24) - .Select(i => - { - var hourStart = now.AddHours(-23 + i); - var targetDate = hourStart.Date; - var targetHour = hourStart.Hour; - return new ExecutionTimePoint - { - Label = hourStart.ToString("HH"), - Completed = hourlyStats - .Where(x => - x.Date == targetDate - && x.Hour == targetHour - && x.TrainState == TrainState.Completed - ) - .Sum(x => x.Count), - Failed = hourlyStats - .Where(x => - x.Date == targetDate - && x.Hour == targetHour - && x.TrainState == TrainState.Failed - ) - .Sum(x => x.Count), - Cancelled = hourlyStats - .Where(x => - x.Date == targetDate - && x.Hour == targetHour - && x.TrainState == TrainState.Cancelled - ) - .Sum(x => x.Count), - }; - }) - .ToList(); - - // Per-minute data (last 60 minutes) - var last60m = now.AddMinutes(-60); - var minuteQuery = context.Metadatas.AsNoTracking().Where(m => m.StartTime >= last60m); - - if (hideAdmin) - minuteQuery = minuteQuery.ExcludeAdmin(adminNames); - - var minuteStats = await minuteQuery - .GroupBy(m => new - { - m.StartTime.Date, - m.StartTime.Hour, - m.StartTime.Minute, - m.TrainState, - }) - .Select(g => new + var hourly = await OperationsService.GetDashboardMetricsAsync( + MetricsRange.Last24Hours, + hideAdmin, + cancellationToken + ); + var minute = await OperationsService.GetDashboardMetricsAsync( + MetricsRange.Last60Minutes, + hideAdmin, + cancellationToken + ); + + _executionsToday = hourly.Kpis.ExecutionsToday; + _successRate = hourly.Kpis.SuccessRate; + _currentlyRunning = hourly.Kpis.CurrentlyRunning; + _unresolvedDeadLetters = hourly.Kpis.UnresolvedDeadLetters; + + _hourlyData = hourly + .ExecutionsOverTime.Select(b => new ExecutionTimePoint { - g.Key.Date, - g.Key.Hour, - g.Key.Minute, - g.Key.TrainState, - Count = g.Count(), + Label = b.Timestamp.ToString("HH"), + Completed = b.Completed, + Failed = b.Failed, + Cancelled = b.Cancelled, }) - .ToListAsync(cancellationToken); - - _minuteData = Enumerable - .Range(0, 60) - .Select(i => - { - var minuteStart = now.AddMinutes(-59 + i); - var targetDate = minuteStart.Date; - var targetHour = minuteStart.Hour; - var targetMinute = minuteStart.Minute; - return new ExecutionTimePoint - { - Label = - i % 5 == 0 ? minuteStart.ToString("HH:mm") : $"\u2009{minuteStart:HH:mm}", - Completed = minuteStats - .Where(x => - x.Date == targetDate - && x.Hour == targetHour - && x.Minute == targetMinute - && x.TrainState == TrainState.Completed - ) - .Sum(x => x.Count), - Failed = minuteStats - .Where(x => - x.Date == targetDate - && x.Hour == targetHour - && x.Minute == targetMinute - && x.TrainState == TrainState.Failed - ) - .Sum(x => x.Count), - Cancelled = minuteStats - .Where(x => - x.Date == targetDate - && x.Hour == targetHour - && x.Minute == targetMinute - && x.TrainState == TrainState.Cancelled - ) - .Sum(x => x.Count), - }; - }) - .ToList(); - - // Top failing trains (last 7 days) - var last7d = now.AddDays(-7); - var failuresQuery = context - .Metadatas.AsNoTracking() - .Where(m => m.TrainState == TrainState.Failed && m.StartTime >= last7d); - - if (hideAdmin) - failuresQuery = failuresQuery.ExcludeAdmin(adminNames); - - _topFailures = ( - await failuresQuery - .GroupBy(m => m.Name) - .Select(g => new TrainFailureCount { Name = g.Key, Count = g.Count() }) - .OrderByDescending(x => x.Count) - .Take(10) - .ToListAsync(cancellationToken) - ) - .Select(x => new TrainFailureCount { Name = ShortName(x.Name), Count = x.Count }) .ToList(); - // Average duration by train (completed in last 7 days) - var durationsQuery = context - .Metadatas.AsNoTracking() - .Where(m => - m.TrainState == TrainState.Completed - && m.EndTime != null - && m.StartTime >= last7d - && m.ParentId == null - ); - - if (hideAdmin) - durationsQuery = durationsQuery.ExcludeAdmin(adminNames); - - var avgDurationData = await durationsQuery - .GroupBy(m => m.Name) - .Select(g => new - { - Name = g.Key, - AvgSeconds = g.Average(m => (m.EndTime!.Value - m.StartTime).TotalSeconds), - }) - .OrderByDescending(x => x.AvgSeconds) - .Take(10) - .ToListAsync(cancellationToken); - - _avgDurations = avgDurationData - .Select(x => new TrainDuration - { - Name = ShortName(x.Name), - AvgMs = Math.Round(x.AvgSeconds * 1000, 0), - }) + _minuteData = minute + .ExecutionsOverTime.Select( + (b, i) => + new ExecutionTimePoint + { + // Thin space prefix on non-5-minute marks suppresses the axis label + // while keeping the data point. + Label = + i % 5 == 0 ? b.Timestamp.ToString("HH:mm") : $" {b.Timestamp:HH:mm}", + Completed = b.Completed, + Failed = b.Failed, + Cancelled = b.Cancelled, + } + ) .ToList(); - // Throughput sparkline (completed per 6h block over 7d, by train) - var throughputQuery = context - .Metadatas.AsNoTracking() - .Where(m => m.TrainState == TrainState.Completed && m.StartTime >= last7d); - - if (hideAdmin) - throughputQuery = throughputQuery.ExcludeAdmin(adminNames); - - var throughputStats = await throughputQuery - .GroupBy(m => new - { - m.StartTime.Date, - Block = m.StartTime.Hour / 6, - m.Name, - }) - .Select(g => new + _topFailures = hourly + .TopFailures.Select(f => new Models.TrainFailureCount { - g.Key.Date, - g.Key.Block, - g.Key.Name, - Count = g.Count(), + Name = ShortName(f.TrainName), + Count = f.Count, }) - .ToListAsync(cancellationToken); - - // Identify top 3 trains by total count - var top3Names = throughputStats - .GroupBy(x => x.Name) - .OrderByDescending(g => g.Sum(x => x.Count)) - .Take(3) - .Select(g => g.Key) .ToList(); - var top3Set = new HashSet(top3Names); - - // Build time block labels - var blockLabels = Enumerable - .Range(0, 28) - .Select(i => + _avgDurations = hourly + .TopAverageDurations.Select(d => new TrainDuration { - var blockStart = now.AddHours(-((27 - i) * 6)); - return new - { - Date = blockStart.Date, - Block = blockStart.Hour / 6, - Label = blockStart.Hour == 0 - ? blockStart.ToString("MMM dd") - : $"\u2009{blockStart:MMM dd HH}", - }; + Name = ShortName(d.TrainName), + AvgMs = Math.Round(d.AverageMilliseconds, 0), }) .ToList(); - // Build one series per top train + "Other" - var seriesNames = top3Names.Append("Other").ToList(); - string[] seriesColors = ["#2E7D32", "#1565C0", "#F9A825", "#78909C"]; - - _throughputSeries = seriesNames - .Select( - (name, idx) => - new ThroughputSeries + _throughputSeries = hourly + .ThroughputSeries.Select( + (s, idx) => + new Models.ThroughputSeries { - Name = name == "Other" ? "Other" : ShortName(name), - Color = seriesColors[idx], - Points = blockLabels - .Select(b => new ThroughputPoint + Name = s.TrainName == "Other" ? "Other" : ShortName(s.TrainName), + Color = SeriesColors[Math.Min(idx, SeriesColors.Length - 1)], + Points = s + .Buckets.Select(b => new ThroughputPoint { - Label = b.Label, - Count = - name == "Other" - ? throughputStats - .Where(x => - x.Date == b.Date - && x.Block == b.Block - && !top3Set.Contains(x.Name) - ) - .Sum(x => x.Count) - : throughputStats - .Where(x => - x.Date == b.Date - && x.Block == b.Block - && x.Name == name - ) - .Sum(x => x.Count), + Label = + b.Timestamp.Hour == 0 + ? b.Timestamp.ToString("MMM dd") + : $" {b.Timestamp:MMM dd HH}", + Count = b.Count, }) .ToList(), } ) - .Where(s => s.Points.Any(p => p.Count > 0)) .ToList(); } private string FormatCategoryLabel(object value) { var label = value?.ToString() ?? ""; - return label.StartsWith('\u2009') ? "" : label; + return label.StartsWith(' ') ? "" : label; } private void CollectServerHealthMetrics() { + // Read memory / uptime through the shared service so the dashboard and the + // GraphQL operations.metrics.server query report the same numbers. + // CPU% requires per-instance sampling state and stays local to this component. + var snap = OperationsService.GetServerMetrics(); + _memoryWorkingSetMb = Math.Round(snap.WorkingSetBytes / 1024.0 / 1024.0, 1); + _gcHeapMb = Math.Round(snap.GcHeapBytes / 1024.0 / 1024.0, 1); + _uptime = TimeSpan.FromSeconds(snap.UptimeSeconds); + using var process = Process.GetCurrentProcess(); var now = DateTime.UtcNow; - var currentCpuTime = process.TotalProcessorTime; var elapsed = (now - _prevSampleTime).TotalMilliseconds; @@ -384,10 +167,5 @@ private void CollectServerHealthMetrics() _prevCpuTime = currentCpuTime; _prevSampleTime = now; - - _memoryWorkingSetMb = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 1); - _gcHeapMb = Math.Round(GC.GetTotalMemory(false) / 1024.0 / 1024.0, 1); - - _uptime = now - process.StartTime.ToUniversalTime(); } } diff --git a/src/Trax.Dashboard/Components/Pages/Settings/ServerSettingsPage.razor.cs b/src/Trax.Dashboard/Components/Pages/Settings/ServerSettingsPage.razor.cs index 123f93c..8a7ee79 100644 --- a/src/Trax.Dashboard/Components/Pages/Settings/ServerSettingsPage.razor.cs +++ b/src/Trax.Dashboard/Components/Pages/Settings/ServerSettingsPage.razor.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Radzen; using Trax.Scheduler.Configuration; +using Trax.Scheduler.Services.Operations; namespace Trax.Dashboard.Components.Pages.Settings; @@ -14,6 +15,9 @@ public partial class ServerSettingsPage [Inject] private NotificationService NotificationService { get; set; } = default!; + [Inject] + private IOperationsService OperationsService { get; set; } = default!; + // ── Scheduler state ── private SchedulerConfiguration? _schedulerConfig; private LocalWorkerOptions? _localWorkerOptions; @@ -173,24 +177,41 @@ private void LoadSchedulerFromConfig() } } - private void SaveScheduler() + private async Task SaveScheduler() { if (_schedulerConfig is null) return; - _schedulerConfig.ManifestManagerPollingInterval = _pollingInterval.ToTimeSpan(); - _schedulerConfig.JobDispatcherPollingInterval = _pollingInterval.ToTimeSpan(); - _schedulerConfig.DefaultRetryDelay = _defaultRetryDelay.ToTimeSpan(); - _schedulerConfig.MaxRetryDelay = _maxRetryDelay.ToTimeSpan(); - _schedulerConfig.DefaultJobTimeout = _defaultJobTimeout.ToTimeSpan(); - _schedulerConfig.StalePendingTimeout = _stalePendingTimeout.ToTimeSpan(); - _schedulerConfig.DeadLetterRetentionPeriod = _deadLetterRetentionPeriod.ToTimeSpan(); + // Route through the shared IOperationsService so the dashboard save and the + // GraphQL operations.config.updateScheduler mutation produce the same write. + // The service mutates the in-memory singleton AND persists the row. + var pollingInterval = _pollingInterval.ToTimeSpan(); + var input = new UpdateSchedulerConfigInput( + ManifestManagerEnabled: _schedulerConfig.ManifestManagerEnabled, + JobDispatcherEnabled: _schedulerConfig.JobDispatcherEnabled, + ManifestManagerPollingInterval: pollingInterval, + JobDispatcherPollingInterval: pollingInterval, + MaxActiveJobs: _schedulerConfig.MaxActiveJobs, + ClearMaxActiveJobs: _schedulerConfig.MaxActiveJobs is null, + DefaultMaxRetries: _schedulerConfig.DefaultMaxRetries, + DefaultRetryDelay: _defaultRetryDelay.ToTimeSpan(), + RetryBackoffMultiplier: _schedulerConfig.RetryBackoffMultiplier, + MaxRetryDelay: _maxRetryDelay.ToTimeSpan(), + DefaultJobTimeout: _defaultJobTimeout.ToTimeSpan(), + StalePendingTimeout: _stalePendingTimeout.ToTimeSpan(), + RecoverStuckJobsOnStartup: _schedulerConfig.RecoverStuckJobsOnStartup, + DeadLetterRetentionPeriod: _deadLetterRetentionPeriod.ToTimeSpan(), + AutoPurgeDeadLetters: _schedulerConfig.AutoPurgeDeadLetters, + LocalWorkerCount: _localWorkerOptions?.WorkerCount, + MetadataCleanupInterval: _schedulerConfig.MetadataCleanup is not null + ? _cleanupInterval.ToTimeSpan() + : null, + MetadataCleanupRetention: _schedulerConfig.MetadataCleanup is not null + ? _cleanupRetentionPeriod.ToTimeSpan() + : null + ); - if (_schedulerConfig.MetadataCleanup is not null) - { - _schedulerConfig.MetadataCleanup.CleanupInterval = _cleanupInterval.ToTimeSpan(); - _schedulerConfig.MetadataCleanup.RetentionPeriod = _cleanupRetentionPeriod.ToTimeSpan(); - } + await OperationsService.UpdateSchedulerConfigAsync(input, CancellationToken.None); SnapshotSchedulerState(); } @@ -295,10 +316,10 @@ private void SnapshotLoggingState() // ── Combined actions ── - private void Save() + private async Task Save() { if (_schedulerAvailable) - SaveScheduler(); + await SaveScheduler(); if (_loggingAvailable) SaveLogging();