Compare commits
5 Commits
d2fb9b8071
...
24f5f91704
| Author | SHA1 | Date | |
|---|---|---|---|
| 24f5f91704 | |||
| 00c9584d03 | |||
| c94a3b41c9 | |||
| e25cdc4441 | |||
| 1f95d57717 |
@@ -1,10 +1,56 @@
|
||||
# DiunaBI Project Context
|
||||
|
||||
> This file is auto-generated for Claude Code to quickly understand the project structure.
|
||||
> Last updated: 2025-12-06
|
||||
> Last updated: 2025-12-08
|
||||
|
||||
## RECENT CHANGES (This Session)
|
||||
|
||||
**Jobs List Sorting and Multi-Select Filtering (Dec 8, 2025):**
|
||||
- ✅ **Fixed Job Sorting** - Changed from single CreatedAt DESC to Priority ASC → JobType → CreatedAt DESC
|
||||
- ✅ **Multi-Select Status Filter** - Replaced single status dropdown with multi-select supporting multiple JobStatus values
|
||||
- ✅ **Auto-Refresh on Filter Change** - Filters now automatically trigger data reload without requiring manual button click
|
||||
- ✅ **API Updates** - JobsController GetAll endpoint accepts `List<JobStatus>? statuses` instead of single status
|
||||
- ✅ **JobService Updates** - Sends status values as integers in query string for multi-select support
|
||||
- Files modified: [JobsController.cs](DiunaBI.API/Controllers/JobsController.cs), [JobService.cs](DiunaBI.UI.Shared/Services/JobService.cs), [Index.razor](DiunaBI.UI.Shared/Pages/Jobs/Index.razor), [Index.razor.cs](DiunaBI.UI.Shared/Pages/Jobs/Index.razor.cs)
|
||||
- Status: Jobs list now sortable by priority/type/date with working multi-select filters
|
||||
|
||||
**User Timezone Support (Dec 8, 2025):**
|
||||
- ✅ **DateTimeHelper Service** - Created JS Interop service to detect user's browser timezone
|
||||
- ✅ **UTC to Local Conversion** - All date displays now show user's local timezone instead of UTC
|
||||
- ✅ **Database Consistency** - Database continues to store UTC (correct), conversion only for display
|
||||
- ✅ **Updated Pages** - Applied timezone conversion to all date fields in:
|
||||
- Jobs Index and Details pages
|
||||
- Layers Details page (CreatedAt, ModifiedAt, record history)
|
||||
- DataInbox Index page
|
||||
- ✅ **Service Registration** - Registered DateTimeHelper as scoped service in DI container
|
||||
- Files created: [DateTimeHelper.cs](DiunaBI.UI.Shared/Services/DateTimeHelper.cs)
|
||||
- Files modified: [ServiceCollectionExtensions.cs](DiunaBI.UI.Shared/Extensions/ServiceCollectionExtensions.cs), [Jobs/Index.razor.cs](DiunaBI.UI.Shared/Pages/Jobs/Index.razor.cs), [Jobs/Details.razor](DiunaBI.UI.Shared/Pages/Jobs/Details.razor), [Layers/Details.razor](DiunaBI.UI.Shared/Pages/Layers/Details.razor), [Layers/Details.razor.cs](DiunaBI.UI.Shared/Pages/Layers/Details.razor.cs), [DataInbox/Index.razor.cs](DiunaBI.UI.Shared/Pages/DataInbox/Index.razor.cs)
|
||||
- Status: All dates display in user's local timezone with format "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
**QueueJob Model Cleanup and AutoImport User (Dec 8, 2025):**
|
||||
- ✅ **Removed Duplicate Fields** - Removed CreatedAtUtc and ModifiedAtUtc from QueueJob (were duplicates of CreatedAt/ModifiedAt)
|
||||
- ✅ **Added ModifiedAt Field** - Was missing, now tracks job modification timestamp
|
||||
- ✅ **AutoImport User ID** - Created User.AutoImportUserId constant: `f392209e-123e-4651-a5a4-0b1d6cf9ff9d`
|
||||
- ✅ **System Operations** - All system-created/modified jobs now use AutoImportUserId for CreatedById and ModifiedById
|
||||
- ✅ **Database Migration** - Created migration: RemoveQueueJobDuplicateUTCFields
|
||||
- Files modified: [QueueJob.cs](DiunaBI.Domain/Entities/QueueJob.cs), [User.cs](DiunaBI.Domain/Entities/User.cs), [JobWorkerService.cs](DiunaBI.Infrastructure/Services/JobWorkerService.cs), [JobSchedulerService.cs](DiunaBI.Infrastructure/Services/JobSchedulerService.cs), [AppDbContext.cs](DiunaBI.Infrastructure/Data/AppDbContext.cs), [JobsController.cs](DiunaBI.API/Controllers/JobsController.cs)
|
||||
- Files created: [20251208205202_RemoveQueueJobDuplicateUTCFields.cs](DiunaBI.Infrastructure/Migrations/20251208205202_RemoveQueueJobDuplicateUTCFields.cs)
|
||||
- Status: QueueJob model cleaned up, all automated operations tracked with AutoImport user ID
|
||||
|
||||
**Job Scheduling UI with JWT Authorization (Dec 8, 2025):**
|
||||
- ✅ **New JWT Endpoints** - Created UI-specific endpoints at `/jobs/ui/schedule/*` with JWT authorization (parallel to API key endpoints)
|
||||
- ✅ **Three Scheduling Options** - MudMenu dropdown in Jobs Index with:
|
||||
- Run All Jobs - schedules all import and process jobs
|
||||
- Run All Imports - schedules import jobs only
|
||||
- Run All Processes - schedules process jobs only
|
||||
- ✅ **JobService Methods** - Added three scheduling methods returning (success, jobsCreated, message) tuples
|
||||
- ✅ **Auto-Refresh** - Jobs list automatically reloads after scheduling with success/failure notifications
|
||||
- ✅ **Dual Authorization** - Existing `/jobs/schedule/{apiKey}` endpoints for automation, new `/jobs/ui/schedule` endpoints for UI users
|
||||
- Files modified: [JobsController.cs](DiunaBI.API/Controllers/JobsController.cs), [JobService.cs](DiunaBI.UI.Shared/Services/JobService.cs), [Index.razor](DiunaBI.UI.Shared/Pages/Jobs/Index.razor), [Index.razor.cs](DiunaBI.UI.Shared/Pages/Jobs/Index.razor.cs)
|
||||
- Status: UI users can now schedule jobs directly from Jobs page using JWT authentication
|
||||
|
||||
---
|
||||
|
||||
**API Key Authorization Fix for Cron Jobs (Dec 6, 2025):**
|
||||
- ✅ **Fixed 401 Unauthorized on API Key Endpoints** - Cron jobs calling `/jobs/schedule` endpoints were getting rejected despite valid API keys
|
||||
- ✅ **Added [AllowAnonymous] Attribute** - Bypasses controller-level `[Authorize]` to allow `[ApiKeyAuth]` filter to handle authorization
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -563,3 +563,10 @@ coverage/
|
||||
##
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
##
|
||||
## LocalDB Development Files
|
||||
##
|
||||
DevTools/LocalDB/backups/*.bak
|
||||
DevTools/LocalDB/backups/*.bacpac
|
||||
DevTools/LocalDB/data/
|
||||
@@ -36,7 +36,7 @@ public class JobsController : Controller
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] int start = 0,
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] JobStatus? status = null,
|
||||
[FromQuery] List<JobStatus>? statuses = null,
|
||||
[FromQuery] JobType? jobType = null,
|
||||
[FromQuery] Guid? layerId = null)
|
||||
{
|
||||
@@ -54,9 +54,9 @@ public class JobsController : Controller
|
||||
|
||||
var query = _db.QueueJobs.AsQueryable();
|
||||
|
||||
if (status.HasValue)
|
||||
if (statuses != null && statuses.Count > 0)
|
||||
{
|
||||
query = query.Where(j => j.Status == status.Value);
|
||||
query = query.Where(j => statuses.Contains(j.Status));
|
||||
}
|
||||
|
||||
if (jobType.HasValue)
|
||||
@@ -71,8 +71,11 @@ public class JobsController : Controller
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
// Sort by: Priority ASC (0=highest), JobType, then CreatedAt DESC
|
||||
var items = await query
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.OrderBy(j => j.Priority)
|
||||
.ThenBy(j => j.JobType)
|
||||
.ThenByDescending(j => j.CreatedAt)
|
||||
.Skip(start)
|
||||
.Take(limit)
|
||||
.AsNoTracking()
|
||||
@@ -201,6 +204,79 @@ public class JobsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// UI-friendly endpoints (JWT auth)
|
||||
[HttpPost]
|
||||
[Route("ui/schedule")]
|
||||
public async Task<IActionResult> ScheduleJobsUI([FromQuery] string? nameFilter = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jobsCreated = await _jobScheduler.ScheduleAllJobsAsync(nameFilter);
|
||||
|
||||
_logger.LogInformation("ScheduleJobsUI: Created {Count} jobs by user {UserId}", jobsCreated, User.Identity?.Name);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
jobsCreated,
|
||||
message = $"Successfully scheduled {jobsCreated} jobs"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "ScheduleJobsUI: Error scheduling jobs");
|
||||
return BadRequest("An error occurred processing your request");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("ui/schedule/imports")]
|
||||
public async Task<IActionResult> ScheduleImportJobsUI([FromQuery] string? nameFilter = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jobsCreated = await _jobScheduler.ScheduleImportJobsAsync(nameFilter);
|
||||
|
||||
_logger.LogInformation("ScheduleImportJobsUI: Created {Count} import jobs by user {UserId}", jobsCreated, User.Identity?.Name);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
jobsCreated,
|
||||
message = $"Successfully scheduled {jobsCreated} import jobs"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "ScheduleImportJobsUI: Error scheduling import jobs");
|
||||
return BadRequest("An error occurred processing your request");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("ui/schedule/processes")]
|
||||
public async Task<IActionResult> ScheduleProcessJobsUI()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jobsCreated = await _jobScheduler.ScheduleProcessJobsAsync();
|
||||
|
||||
_logger.LogInformation("ScheduleProcessJobsUI: Created {Count} process jobs by user {UserId}", jobsCreated, User.Identity?.Name);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
jobsCreated,
|
||||
message = $"Successfully scheduled {jobsCreated} process jobs"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "ScheduleProcessJobsUI: Error scheduling process jobs");
|
||||
return BadRequest("An error occurred processing your request");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("{id:guid}/retry")]
|
||||
public async Task<IActionResult> RetryJob(Guid id)
|
||||
@@ -224,7 +300,8 @@ public class JobsController : Controller
|
||||
job.Status = JobStatus.Pending;
|
||||
job.RetryCount = 0;
|
||||
job.LastError = null;
|
||||
job.ModifiedAtUtc = DateTime.UtcNow;
|
||||
job.ModifiedAt = DateTime.UtcNow;
|
||||
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
@@ -271,7 +348,8 @@ public class JobsController : Controller
|
||||
|
||||
job.Status = JobStatus.Failed;
|
||||
job.LastError = "Cancelled by user";
|
||||
job.ModifiedAtUtc = DateTime.UtcNow;
|
||||
job.ModifiedAt = DateTime.UtcNow;
|
||||
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
@@ -402,10 +480,9 @@ public class JobsController : Controller
|
||||
MaxRetries = maxRetries,
|
||||
Status = JobStatus.Pending,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
ModifiedAtUtc = DateTime.UtcNow,
|
||||
CreatedById = Guid.Empty,
|
||||
ModifiedById = Guid.Empty
|
||||
ModifiedAt = DateTime.UtcNow,
|
||||
CreatedById = DiunaBI.Domain.Entities.User.AutoImportUserId,
|
||||
ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId
|
||||
};
|
||||
|
||||
_db.QueueJobs.Add(job);
|
||||
|
||||
@@ -12,6 +12,7 @@ public class QueueJob
|
||||
public JobType JobType { get; set; }
|
||||
public int Priority { get; set; } = 0; // 0 = highest priority
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
|
||||
public int RetryCount { get; set; } = 0;
|
||||
public int MaxRetries { get; set; } = 5;
|
||||
public JobStatus Status { get; set; } = JobStatus.Pending;
|
||||
@@ -19,9 +20,7 @@ public class QueueJob
|
||||
public DateTime? LastAttemptAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public Guid CreatedById { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public Guid ModifiedById { get; set; }
|
||||
public DateTime ModifiedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public enum JobType
|
||||
|
||||
@@ -5,6 +5,11 @@ namespace DiunaBI.Domain.Entities;
|
||||
|
||||
public class User
|
||||
{
|
||||
/// <summary>
|
||||
/// System user ID for automated operations (imports, scheduled jobs, etc.)
|
||||
/// </summary>
|
||||
public static readonly Guid AutoImportUserId = Guid.Parse("f392209e-123e-4651-a5a4-0b1d6cf9ff9d");
|
||||
|
||||
#region Properties
|
||||
public Guid Id { get; init; }
|
||||
public string? Email { get; init; }
|
||||
|
||||
@@ -136,9 +136,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
modelBuilder.Entity<QueueJob>().Property(x => x.LastAttemptAt);
|
||||
modelBuilder.Entity<QueueJob>().Property(x => x.CompletedAt);
|
||||
modelBuilder.Entity<QueueJob>().Property(x => x.CreatedById).IsRequired();
|
||||
modelBuilder.Entity<QueueJob>().Property(x => x.CreatedAtUtc).IsRequired();
|
||||
modelBuilder.Entity<QueueJob>().Property(x => x.ModifiedById).IsRequired();
|
||||
modelBuilder.Entity<QueueJob>().Property(x => x.ModifiedAtUtc).IsRequired();
|
||||
modelBuilder.Entity<QueueJob>().Property(x => x.ModifiedAt).IsRequired();
|
||||
|
||||
// Configure automatic timestamps for entities with CreatedAt/ModifiedAt
|
||||
ConfigureTimestamps(modelBuilder);
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
|
||||
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.68.0.3525" />
|
||||
<PackageReference Include="Google.Apis.Drive.v3" Version="1.68.0.3490" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
489
DiunaBI.Infrastructure/Migrations/20251208205202_RemoveQueueJobDuplicateUTCFields.Designer.cs
generated
Normal file
489
DiunaBI.Infrastructure/Migrations/20251208205202_RemoveQueueJobDuplicateUTCFields.Designer.cs
generated
Normal file
@@ -0,0 +1,489 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DiunaBI.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DiunaBI.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20251208205202_RemoveQueueJobDuplicateUTCFields")]
|
||||
partial class RemoveQueueJobDuplicateUTCFields
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.DataInbox", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<string>("Data")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DataInbox");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("IsCancelled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<Guid>("ModifiedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedById");
|
||||
|
||||
b.HasIndex("ModifiedById");
|
||||
|
||||
b.ToTable("Layers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.ProcessSource", b =>
|
||||
{
|
||||
b.Property<Guid>("LayerId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("SourceId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("LayerId", "SourceId");
|
||||
|
||||
b.HasIndex("SourceId");
|
||||
|
||||
b.ToTable("ProcessSources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.QueueJob", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("LastAttemptAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<Guid>("LayerId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("LayerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("MaxRetries")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<Guid>("ModifiedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("PluginName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("RetryCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("QueueJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.Record", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Desc1")
|
||||
.HasMaxLength(10000)
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<Guid>("LayerId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<Guid>("ModifiedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<double?>("Value1")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value10")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value11")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value12")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value13")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value14")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value15")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value16")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value17")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value18")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value19")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value2")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value20")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value21")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value22")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value23")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value24")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value25")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value26")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value27")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value28")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value29")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value3")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value30")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value31")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value32")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value4")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value5")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value6")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value7")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value8")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<double?>("Value9")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedById");
|
||||
|
||||
b.HasIndex("LayerId");
|
||||
|
||||
b.HasIndex("ModifiedById");
|
||||
|
||||
b.ToTable("Records");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("ChangeType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("ChangedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid>("ChangedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ChangedFields")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("ChangesSummary")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Desc1")
|
||||
.HasMaxLength(10000)
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("LayerId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("RecordId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChangedById");
|
||||
|
||||
b.HasIndex("LayerId", "ChangedAt");
|
||||
|
||||
b.HasIndex("RecordId", "ChangedAt");
|
||||
|
||||
b.ToTable("RecordHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
|
||||
{
|
||||
b.HasOne("DiunaBI.Domain.Entities.User", "CreatedBy")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DiunaBI.Domain.Entities.User", "ModifiedBy")
|
||||
.WithMany()
|
||||
.HasForeignKey("ModifiedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CreatedBy");
|
||||
|
||||
b.Navigation("ModifiedBy");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.ProcessSource", b =>
|
||||
{
|
||||
b.HasOne("DiunaBI.Domain.Entities.Layer", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("LayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DiunaBI.Domain.Entities.Layer", "Source")
|
||||
.WithMany()
|
||||
.HasForeignKey("SourceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Source");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.Record", b =>
|
||||
{
|
||||
b.HasOne("DiunaBI.Domain.Entities.User", "CreatedBy")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DiunaBI.Domain.Entities.Layer", null)
|
||||
.WithMany("Records")
|
||||
.HasForeignKey("LayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DiunaBI.Domain.Entities.User", "ModifiedBy")
|
||||
.WithMany()
|
||||
.HasForeignKey("ModifiedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CreatedBy");
|
||||
|
||||
b.Navigation("ModifiedBy");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b =>
|
||||
{
|
||||
b.HasOne("DiunaBI.Domain.Entities.User", "ChangedBy")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChangedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ChangedBy");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
|
||||
{
|
||||
b.Navigation("Records");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DiunaBI.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveQueueJobDuplicateUTCFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedAtUtc",
|
||||
table: "QueueJobs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ModifiedAtUtc",
|
||||
table: "QueueJobs");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ModifiedAt",
|
||||
table: "QueueJobs",
|
||||
type: "datetime2",
|
||||
nullable: false,
|
||||
defaultValueSql: "GETUTCDATE()");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ModifiedAt",
|
||||
table: "QueueJobs");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreatedAtUtc",
|
||||
table: "QueueJobs",
|
||||
type: "datetime2",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ModifiedAtUtc",
|
||||
table: "QueueJobs",
|
||||
type: "datetime2",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DataInbox", (string)null);
|
||||
b.ToTable("DataInbox");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
|
||||
@@ -104,7 +104,7 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("ModifiedById");
|
||||
|
||||
b.ToTable("Layers", (string)null);
|
||||
b.ToTable("Layers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.ProcessSource", b =>
|
||||
@@ -119,7 +119,7 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("SourceId");
|
||||
|
||||
b.ToTable("ProcessSources", (string)null);
|
||||
b.ToTable("ProcessSources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.QueueJob", b =>
|
||||
@@ -136,9 +136,6 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
@@ -163,8 +160,10 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
b.Property<int>("MaxRetries")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("ModifiedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<Guid>("ModifiedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
@@ -185,7 +184,7 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("QueueJobs", (string)null);
|
||||
b.ToTable("QueueJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.Record", b =>
|
||||
@@ -329,7 +328,7 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("ModifiedById");
|
||||
|
||||
b.ToTable("Records", (string)null);
|
||||
b.ToTable("Records");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b =>
|
||||
@@ -378,7 +377,7 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("RecordId", "ChangedAt");
|
||||
|
||||
b.ToTable("RecordHistory", (string)null);
|
||||
b.ToTable("RecordHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.User", b =>
|
||||
@@ -402,7 +401,7 @@ namespace DiunaBI.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users", (string)null);
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
|
||||
|
||||
@@ -85,10 +85,9 @@ public class JobSchedulerService
|
||||
MaxRetries = maxRetries,
|
||||
Status = JobStatus.Pending,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
ModifiedAtUtc = DateTime.UtcNow,
|
||||
CreatedById = Guid.Empty, // System user
|
||||
ModifiedById = Guid.Empty
|
||||
ModifiedAt = DateTime.UtcNow,
|
||||
CreatedById = DiunaBI.Domain.Entities.User.AutoImportUserId,
|
||||
ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId
|
||||
};
|
||||
|
||||
_db.QueueJobs.Add(job);
|
||||
@@ -175,10 +174,9 @@ public class JobSchedulerService
|
||||
MaxRetries = maxRetries,
|
||||
Status = JobStatus.Pending,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
ModifiedAtUtc = DateTime.UtcNow,
|
||||
CreatedById = Guid.Empty,
|
||||
ModifiedById = Guid.Empty
|
||||
ModifiedAt = DateTime.UtcNow,
|
||||
CreatedById = DiunaBI.Domain.Entities.User.AutoImportUserId,
|
||||
ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId
|
||||
};
|
||||
|
||||
_db.QueueJobs.Add(job);
|
||||
|
||||
@@ -66,7 +66,8 @@ public class JobWorkerService : BackgroundService
|
||||
// Mark job as running
|
||||
job.Status = JobStatus.Running;
|
||||
job.LastAttemptAt = DateTime.UtcNow;
|
||||
job.ModifiedAtUtc = DateTime.UtcNow;
|
||||
job.ModifiedAt = DateTime.UtcNow;
|
||||
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
try
|
||||
@@ -114,7 +115,8 @@ public class JobWorkerService : BackgroundService
|
||||
job.Status = JobStatus.Completed;
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
job.LastError = null;
|
||||
job.ModifiedAtUtc = DateTime.UtcNow;
|
||||
job.ModifiedAt = DateTime.UtcNow;
|
||||
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
|
||||
|
||||
_logger.LogInformation("JobWorker: Job {JobId} completed successfully", job.Id);
|
||||
|
||||
@@ -131,7 +133,8 @@ public class JobWorkerService : BackgroundService
|
||||
|
||||
// Capture full error details including inner exceptions
|
||||
job.LastError = GetFullErrorMessage(ex);
|
||||
job.ModifiedAtUtc = DateTime.UtcNow;
|
||||
job.ModifiedAt = DateTime.UtcNow;
|
||||
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
|
||||
|
||||
if (job.RetryCount >= job.MaxRetries)
|
||||
{
|
||||
@@ -157,7 +160,8 @@ public class JobWorkerService : BackgroundService
|
||||
|
||||
// Increment retry count for next attempt
|
||||
job.RetryCount++;
|
||||
job.ModifiedAtUtc = DateTime.UtcNow;
|
||||
job.ModifiedAt = DateTime.UtcNow;
|
||||
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -44,6 +44,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<LayerService>();
|
||||
services.AddScoped<DataInboxService>();
|
||||
services.AddScoped<JobService>();
|
||||
services.AddScoped<DateTimeHelper>();
|
||||
|
||||
// Filter state services (scoped to maintain state during user session)
|
||||
services.AddScoped<LayerFilterStateService>();
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<RowTemplate Context="row">
|
||||
<MudTd DataLabel="Name"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@row.Name</div></MudTd>
|
||||
<MudTd DataLabel="Source"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@row.Source</div></MudTd>
|
||||
<MudTd DataLabel="Created At"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@row.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</div></MudTd>
|
||||
<MudTd DataLabel="Created At"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@DateTimeHelper.FormatDateTime(row.CreatedAt)</div></MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText>No data inbox items to display</MudText>
|
||||
|
||||
@@ -15,6 +15,7 @@ public partial class Index : ComponentBase
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||
[Inject] private DataInboxFilterStateService FilterStateService { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
[Inject] private DateTimeHelper DateTimeHelper { get; set; } = default!;
|
||||
|
||||
|
||||
private PagedResult<DataInboxDto> dataInbox = new();
|
||||
@@ -23,6 +24,7 @@ public partial class Index : ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await DateTimeHelper.InitializeAsync();
|
||||
filterRequest = FilterStateService.FilterRequest;
|
||||
await LoadDataInbox();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@inject EntityChangeHubService HubService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject DateTimeHelper DateTimeHelper
|
||||
@implements IDisposable
|
||||
|
||||
<MudCard>
|
||||
@@ -92,14 +93,14 @@
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField Value="@job.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")"
|
||||
<MudTextField Value="@DateTimeHelper.FormatDateTime(job.CreatedAt)"
|
||||
Label="Created At"
|
||||
Variant="Variant.Outlined"
|
||||
ReadOnly="true"
|
||||
FullWidth="true"/>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField Value="@(job.LastAttemptAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")"
|
||||
<MudTextField Value="@DateTimeHelper.FormatDateTime(job.LastAttemptAt)"
|
||||
Label="Last Attempt At"
|
||||
Variant="Variant.Outlined"
|
||||
ReadOnly="true"
|
||||
@@ -107,7 +108,7 @@
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField Value="@(job.CompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")"
|
||||
<MudTextField Value="@DateTimeHelper.FormatDateTime(job.CompletedAt)"
|
||||
Label="Completed At"
|
||||
Variant="Variant.Outlined"
|
||||
ReadOnly="true"
|
||||
@@ -161,6 +162,7 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await DateTimeHelper.InitializeAsync();
|
||||
await LoadJob();
|
||||
|
||||
// Subscribe to SignalR entity changes
|
||||
|
||||
@@ -12,25 +12,28 @@
|
||||
Expanded="true">
|
||||
<MudGrid AlignItems="Center">
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="JobStatus?"
|
||||
@bind-Value="selectedStatus"
|
||||
<MudSelect T="JobStatus"
|
||||
SelectedValues="selectedStatuses"
|
||||
Label="Status"
|
||||
Placeholder="All statuses"
|
||||
MultiSelection="true"
|
||||
Clearable="true"
|
||||
SelectedValuesChanged="OnStatusFilterChanged"
|
||||
OnClearButtonClick="OnStatusClear">
|
||||
@foreach (JobStatus status in Enum.GetValues(typeof(JobStatus)))
|
||||
{
|
||||
<MudSelectItem T="JobStatus?" Value="@status">@status.ToString()</MudSelectItem>
|
||||
<MudSelectItem T="JobStatus" Value="@status">@status.ToString()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="JobType?"
|
||||
@bind-Value="selectedJobType"
|
||||
Value="selectedJobType"
|
||||
Label="Job Type"
|
||||
Placeholder="All types"
|
||||
Clearable="true"
|
||||
ValueChanged="OnJobTypeFilterChanged"
|
||||
OnClearButtonClick="OnJobTypeClear">
|
||||
@foreach (JobType type in Enum.GetValues(typeof(JobType)))
|
||||
{
|
||||
@@ -39,7 +42,33 @@
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" sm="12" md="6" Class="d-flex justify-end align-center">
|
||||
<MudItem xs="12" sm="12" md="6" Class="d-flex justify-end align-center gap-2">
|
||||
<MudMenu Icon="@Icons.Material.Filled.PlayArrow"
|
||||
Label="Schedule Jobs"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
Size="Size.Medium"
|
||||
EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
|
||||
<MudMenuItem OnClick="@(() => ScheduleJobs("all"))">
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.PlayCircle" Class="mr-2" />
|
||||
<span>Run All Jobs</span>
|
||||
</div>
|
||||
</MudMenuItem>
|
||||
<MudMenuItem OnClick="@(() => ScheduleJobs("imports"))">
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.FileDownload" Class="mr-2" />
|
||||
<span>Run All Imports</span>
|
||||
</div>
|
||||
</MudMenuItem>
|
||||
<MudMenuItem OnClick="@(() => ScheduleJobs("processes"))">
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Class="mr-2" />
|
||||
<span>Run All Processes</span>
|
||||
</div>
|
||||
</MudMenuItem>
|
||||
</MudMenu>
|
||||
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="LoadJobs"
|
||||
Color="Color.Primary"
|
||||
@@ -108,12 +137,12 @@
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Created">
|
||||
<div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">
|
||||
@row.CreatedAt.ToString("yyyy-MM-dd HH:mm")
|
||||
@DateTimeHelper.FormatDateTime(row.CreatedAt, "yyyy-MM-dd HH:mm")
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Last Attempt">
|
||||
<div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">
|
||||
@(row.LastAttemptAt?.ToString("yyyy-MM-dd HH:mm") ?? "-")
|
||||
@DateTimeHelper.FormatDateTime(row.LastAttemptAt, "yyyy-MM-dd HH:mm")
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
|
||||
@@ -15,16 +15,18 @@ public partial class Index : ComponentBase, IDisposable
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = default!;
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
[Inject] private DateTimeHelper DateTimeHelper { get; set; } = default!;
|
||||
|
||||
private PagedResult<QueueJob> jobs = new();
|
||||
private bool isLoading = false;
|
||||
private int currentPage = 1;
|
||||
private int pageSize = 50;
|
||||
private JobStatus? selectedStatus = null;
|
||||
private IEnumerable<JobStatus> selectedStatuses = new HashSet<JobStatus>();
|
||||
private JobType? selectedJobType = null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await DateTimeHelper.InitializeAsync();
|
||||
await LoadJobs();
|
||||
|
||||
// Subscribe to SignalR entity changes
|
||||
@@ -60,7 +62,8 @@ public partial class Index : ComponentBase, IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
jobs = await JobService.GetJobsAsync(currentPage, pageSize, selectedStatus, selectedJobType);
|
||||
var statusList = selectedStatuses?.ToList();
|
||||
jobs = await JobService.GetJobsAsync(currentPage, pageSize, statusList, selectedJobType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -81,15 +84,29 @@ public partial class Index : ComponentBase, IDisposable
|
||||
|
||||
private async Task ClearFilters()
|
||||
{
|
||||
selectedStatus = null;
|
||||
selectedStatuses = new HashSet<JobStatus>();
|
||||
selectedJobType = null;
|
||||
currentPage = 1;
|
||||
await LoadJobs();
|
||||
}
|
||||
|
||||
private async Task OnStatusFilterChanged(IEnumerable<JobStatus> values)
|
||||
{
|
||||
selectedStatuses = values;
|
||||
currentPage = 1;
|
||||
await LoadJobs();
|
||||
}
|
||||
|
||||
private async Task OnJobTypeFilterChanged(JobType? value)
|
||||
{
|
||||
selectedJobType = value;
|
||||
currentPage = 1;
|
||||
await LoadJobs();
|
||||
}
|
||||
|
||||
private async Task OnStatusClear()
|
||||
{
|
||||
selectedStatus = null;
|
||||
selectedStatuses = new HashSet<JobStatus>();
|
||||
currentPage = 1;
|
||||
await LoadJobs();
|
||||
}
|
||||
@@ -112,6 +129,41 @@ public partial class Index : ComponentBase, IDisposable
|
||||
await JSRuntime.InvokeVoidAsync("open", url, "_blank");
|
||||
}
|
||||
|
||||
private async Task ScheduleJobs(string type)
|
||||
{
|
||||
isLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
(bool success, int jobsCreated, string message) result = type switch
|
||||
{
|
||||
"all" => await JobService.ScheduleAllJobsAsync(),
|
||||
"imports" => await JobService.ScheduleImportJobsAsync(),
|
||||
"processes" => await JobService.ScheduleProcessJobsAsync(),
|
||||
_ => (false, 0, "Unknown job type")
|
||||
};
|
||||
|
||||
if (result.success)
|
||||
{
|
||||
Snackbar.Add($"{result.message} ({result.jobsCreated} jobs created)", Severity.Success);
|
||||
await LoadJobs();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(result.message, Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Scheduling jobs failed: {ex.Message}");
|
||||
Snackbar.Add($"Failed to schedule jobs: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetStatusColor(JobStatus status)
|
||||
{
|
||||
return status switch
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField Value="@layer.CreatedAt.ToString("g")"
|
||||
<MudTextField Value="@DateTimeHelper.FormatDateTime(layer.CreatedAt, "yyyy-MM-dd HH:mm")"
|
||||
Label="Created"
|
||||
Variant="Variant.Outlined"
|
||||
ReadOnly="true"
|
||||
@@ -67,7 +67,7 @@
|
||||
AdornmentText="@(layer.CreatedBy?.Username ?? "")"/>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField Value="@layer.ModifiedAt.ToString("g")"
|
||||
<MudTextField Value="@DateTimeHelper.FormatDateTime(layer.ModifiedAt, "yyyy-MM-dd HH:mm")"
|
||||
Label="Modified"
|
||||
Variant="Variant.Outlined"
|
||||
ReadOnly="true"
|
||||
@@ -316,7 +316,7 @@
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Code">@context.Code</MudTd>
|
||||
<MudTd DataLabel="Description">@context.Desc1</MudTd>
|
||||
<MudTd DataLabel="Modified">@context.ModifiedAt.ToString("g")</MudTd>
|
||||
<MudTd DataLabel="Modified">@DateTimeHelper.FormatDateTime(context.ModifiedAt, "yyyy-MM-dd HH:mm")</MudTd>
|
||||
<MudTd DataLabel="Modified By">@GetModifiedByUsername(context.ModifiedById)</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@@ -26,6 +26,9 @@ public partial class Details : ComponentBase
|
||||
[Inject]
|
||||
private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private DateTimeHelper DateTimeHelper { get; set; } = null!;
|
||||
|
||||
private LayerDto? layer;
|
||||
private List<RecordDto> records = new();
|
||||
private List<string> displayedColumns = new();
|
||||
@@ -52,6 +55,7 @@ public partial class Details : ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await DateTimeHelper.InitializeAsync();
|
||||
await LoadLayer();
|
||||
}
|
||||
|
||||
|
||||
80
DiunaBI.UI.Shared/Services/DateTimeHelper.cs
Normal file
80
DiunaBI.UI.Shared/Services/DateTimeHelper.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DiunaBI.UI.Shared.Services;
|
||||
|
||||
public class DateTimeHelper
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private TimeZoneInfo? _userTimeZone;
|
||||
private bool _initialized = false;
|
||||
|
||||
public DateTimeHelper(IJSRuntime jsRuntime)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Get the user's timezone from JavaScript
|
||||
var timeZoneId = await _jsRuntime.InvokeAsync<string>("eval", "Intl.DateTimeFormat().resolvedOptions().timeZone");
|
||||
|
||||
// Try to find the TimeZoneInfo
|
||||
try
|
||||
{
|
||||
_userTimeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to local timezone if the IANA timezone ID is not found
|
||||
_userTimeZone = TimeZoneInfo.Local;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to local timezone if JavaScript interop fails
|
||||
_userTimeZone = TimeZoneInfo.Local;
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
public string FormatDateTime(DateTime? dateTime, string format = "yyyy-MM-dd HH:mm:ss")
|
||||
{
|
||||
if (!dateTime.HasValue)
|
||||
return "-";
|
||||
|
||||
if (!_initialized)
|
||||
{
|
||||
// If not initialized yet, just format as-is (will be UTC)
|
||||
return dateTime.Value.ToString(format);
|
||||
}
|
||||
|
||||
// Convert UTC to user's timezone
|
||||
var localDateTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime.Value, _userTimeZone ?? TimeZoneInfo.Local);
|
||||
return localDateTime.ToString(format);
|
||||
}
|
||||
|
||||
public string FormatDate(DateTime? dateTime, string format = "yyyy-MM-dd")
|
||||
{
|
||||
return FormatDateTime(dateTime, format);
|
||||
}
|
||||
|
||||
public string FormatTime(DateTime? dateTime, string format = "HH:mm:ss")
|
||||
{
|
||||
return FormatDateTime(dateTime, format);
|
||||
}
|
||||
|
||||
public string GetTimeZoneAbbreviation()
|
||||
{
|
||||
if (!_initialized || _userTimeZone == null)
|
||||
return "UTC";
|
||||
|
||||
return _userTimeZone.IsDaylightSavingTime(DateTime.Now)
|
||||
? _userTimeZone.DaylightName
|
||||
: _userTimeZone.StandardName;
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,18 @@ public class JobService
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public async Task<PagedResult<QueueJob>> GetJobsAsync(int page = 1, int pageSize = 50, JobStatus? status = null, JobType? jobType = null, Guid? layerId = null)
|
||||
public async Task<PagedResult<QueueJob>> GetJobsAsync(int page = 1, int pageSize = 50, List<JobStatus>? statuses = null, JobType? jobType = null, Guid? layerId = null)
|
||||
{
|
||||
var start = (page - 1) * pageSize;
|
||||
var query = $"Jobs?start={start}&limit={pageSize}";
|
||||
|
||||
if (status.HasValue)
|
||||
query += $"&status={(int)status.Value}";
|
||||
if (statuses != null && statuses.Count > 0)
|
||||
{
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
query += $"&statuses={(int)status}";
|
||||
}
|
||||
}
|
||||
|
||||
if (jobType.HasValue)
|
||||
query += $"&jobType={(int)jobType.Value}";
|
||||
@@ -83,6 +88,89 @@ public class JobService
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<CreateJobResult>();
|
||||
}
|
||||
|
||||
public async Task<(bool success, int jobsCreated, string message)> ScheduleAllJobsAsync(string? nameFilter = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = string.IsNullOrEmpty(nameFilter) ? "" : $"?nameFilter={Uri.EscapeDataString(nameFilter)}";
|
||||
var response = await _httpClient.PostAsync($"Jobs/ui/schedule{query}", null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return (false, 0, $"Failed to schedule jobs: {error}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(json, _jsonOptions);
|
||||
|
||||
var jobsCreated = result.GetProperty("jobsCreated").GetInt32();
|
||||
var message = result.GetProperty("message").GetString() ?? "Jobs scheduled";
|
||||
|
||||
return (true, jobsCreated, message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Scheduling jobs failed: {ex.Message}");
|
||||
return (false, 0, $"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool success, int jobsCreated, string message)> ScheduleImportJobsAsync(string? nameFilter = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = string.IsNullOrEmpty(nameFilter) ? "" : $"?nameFilter={Uri.EscapeDataString(nameFilter)}";
|
||||
var response = await _httpClient.PostAsync($"Jobs/ui/schedule/imports{query}", null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return (false, 0, $"Failed to schedule import jobs: {error}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(json, _jsonOptions);
|
||||
|
||||
var jobsCreated = result.GetProperty("jobsCreated").GetInt32();
|
||||
var message = result.GetProperty("message").GetString() ?? "Import jobs scheduled";
|
||||
|
||||
return (true, jobsCreated, message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Scheduling import jobs failed: {ex.Message}");
|
||||
return (false, 0, $"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool success, int jobsCreated, string message)> ScheduleProcessJobsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsync("Jobs/ui/schedule/processes", null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return (false, 0, $"Failed to schedule process jobs: {error}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(json, _jsonOptions);
|
||||
|
||||
var jobsCreated = result.GetProperty("jobsCreated").GetInt32();
|
||||
var message = result.GetProperty("message").GetString() ?? "Process jobs scheduled";
|
||||
|
||||
return (true, jobsCreated, message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Scheduling process jobs failed: {ex.Message}");
|
||||
return (false, 0, $"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class JobStats
|
||||
|
||||
Reference in New Issue
Block a user