Compare commits

...

16 Commits

Author SHA1 Message Date
16eb688607 Clients logo 2025-12-10 12:28:36 +01:00
2132c130a3 update changelog 2025-12-08 23:09:54 +01:00
dffbc31432 Refactor job sorting logic, reduce poll interval, and implement SignalR subscriptions for real-time updates in DataInbox and Layers pages
All checks were successful
Build Docker Images / test (map[name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m28s
Build Docker Images / test (map[name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m26s
Build Docker Images / build-and-push (map[image_suffix:morska name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m38s
Build Docker Images / build-and-push (map[image_suffix:pedrollopl name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m38s
2025-12-08 23:08:46 +01:00
151ecaa98f Fix job scheduler race condition and enhance Blazor reconnection UI
All checks were successful
Build Docker Images / test (map[name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m27s
Build Docker Images / test (map[name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m23s
Build Docker Images / build-and-push (map[image_suffix:morska name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m43s
Build Docker Images / build-and-push (map[image_suffix:pedrollopl name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m39s
2025-12-08 22:45:31 +01:00
b917aa5077 Add Blazor Server reconnection modal and timer functionality 2025-12-08 22:30:31 +01:00
24f5f91704 update readme
All checks were successful
Build Docker Images / test (map[name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m28s
Build Docker Images / test (map[name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m27s
Build Docker Images / build-and-push (map[image_suffix:morska name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m41s
Build Docker Images / build-and-push (map[image_suffix:pedrollopl name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m41s
2025-12-08 22:07:16 +01:00
00c9584d03 Schedule Jobs from UI 2025-12-08 22:02:57 +01:00
c94a3b41c9 Duplicate models fields fix 2025-12-08 21:54:48 +01:00
e25cdc4441 UI timezone 2025-12-08 21:42:10 +01:00
1f95d57717 JobList filter fix 2025-12-08 21:28:24 +01:00
d2fb9b8071 Fix API Key Authorization for Cron Jobs by adding [AllowAnonymous] attribute to scheduling endpoints
All checks were successful
Build Docker Images / test (map[name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m29s
Build Docker Images / test (map[name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m29s
Build Docker Images / build-and-push (map[image_suffix:morska name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m46s
Build Docker Images / build-and-push (map[image_suffix:pedrollopl name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m49s
2025-12-06 00:50:20 +01:00
08abd96751 SignalR FIX
All checks were successful
Build Docker Images / test (map[name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m26s
Build Docker Images / test (map[name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m24s
Build Docker Images / build-and-push (map[image_suffix:morska name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m41s
Build Docker Images / build-and-push (map[image_suffix:pedrollopl name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m38s
2025-12-06 00:36:22 +01:00
eb570679ba UI Fix
All checks were successful
Build Docker Images / test (map[name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m28s
Build Docker Images / test (map[name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m26s
Build Docker Images / build-and-push (map[image_suffix:morska name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m40s
Build Docker Images / build-and-push (map[image_suffix:pedrollopl name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m39s
2025-12-06 00:03:46 +01:00
8713ed9686 LayerDetail improvement
All checks were successful
Build Docker Images / test (map[name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m27s
Build Docker Images / test (map[name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m29s
Build Docker Images / build-and-push (map[image_suffix:morska name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m39s
Build Docker Images / build-and-push (map[image_suffix:pedrollopl name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m37s
2025-12-05 23:49:16 +01:00
595076033b More security!
All checks were successful
Build Docker Images / test (map[name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m25s
Build Docker Images / test (map[name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m25s
Build Docker Images / build-and-push (map[image_suffix:morska name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m41s
Build Docker Images / build-and-push (map[image_suffix:pedrollopl name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m40s
2025-12-05 23:41:56 +01:00
0c874575d4 SignalR Security 2025-12-05 23:17:02 +01:00
42 changed files with 1638 additions and 144 deletions

View File

@@ -1,10 +1,151 @@
# DiunaBI Project Context # DiunaBI Project Context
> This file is auto-generated for Claude Code to quickly understand the project structure. > This file is auto-generated for Claude Code to quickly understand the project structure.
> Last updated: 2025-12-05 > Last updated: 2025-12-08
## RECENT CHANGES (This Session) ## RECENT CHANGES (This Session)
**SignalR Real-Time Updates & UI Consistency (Dec 8, 2025):**
-**Removed Manual Refresh Button** - Removed refresh button from Jobs/Index.razor (SignalR auto-refresh eliminates need)
-**SignalR on Layers List** - Added real-time updates to Layers/Index with EntityChangeHubService subscription
-**SignalR on DataInbox List** - Added real-time updates to DataInbox/Index with EntityChangeHubService subscription
-**SignalR on Layer Details** - Added real-time updates to Layers/Details for both layer and record changes
-**Consistent UI Behavior** - All lists now have uniform SignalR-based real-time updates
-**Proper Cleanup** - Implemented IDisposable pattern to unsubscribe from SignalR events on all pages
-**Jobs Sorting Fix** - Changed sorting from Priority→JobType→CreatedAt DESC to CreatedAt DESC→Priority ASC (newest jobs first, then by priority)
-**Faster Job Processing** - Reduced JobWorkerService poll interval from 10 seconds to 5 seconds
- Files modified:
- [Jobs/Index.razor](DiunaBI.UI.Shared/Pages/Jobs/Index.razor) - removed refresh button
- [Layers/Index.razor](DiunaBI.UI.Shared/Pages/Layers/Index.razor), [Layers/Index.razor.cs](DiunaBI.UI.Shared/Pages/Layers/Index.razor.cs) - added SignalR + IDisposable
- [DataInbox/Index.razor](DiunaBI.UI.Shared/Pages/DataInbox/Index.razor), [DataInbox/Index.razor.cs](DiunaBI.UI.Shared/Pages/DataInbox/Index.razor.cs) - added SignalR + IDisposable
- [Layers/Details.razor](DiunaBI.UI.Shared/Pages/Layers/Details.razor), [Layers/Details.razor.cs](DiunaBI.UI.Shared/Pages/Layers/Details.razor.cs) - added SignalR + IDisposable
- [JobsController.cs](DiunaBI.API/Controllers/JobsController.cs) - fixed sorting logic
- [JobWorkerService.cs](DiunaBI.Infrastructure/Services/JobWorkerService.cs) - reduced poll interval to 5 seconds
- Status: All lists have consistent real-time behavior, no manual refresh needed, jobs sorted by date first
---
**Job Scheduler Race Condition Fix (Dec 8, 2025):**
-**In-Memory Deduplication** - Added `HashSet<Guid>` to track LayerIds scheduled within the same batch
-**Prevents Duplicate Jobs** - Fixed race condition where same layer could be scheduled multiple times during single "Run All Jobs" operation
-**Two-Level Protection** - In-memory check (HashSet) runs before database check for O(1) performance
-**Applied to Both Methods** - Fixed both ScheduleImportJobsAsync and ScheduleProcessJobsAsync
-**Better Logging** - Added debug log message "Job already scheduled in this batch" for transparency
- Root cause: When multiple layers had same ID in query results or import plugins created new layers during scheduling loop, database check couldn't detect duplicates added in same batch before SaveChangesAsync()
- Solution: Track scheduled LayerIds in HashSet during loop iteration to prevent within-batch duplicates
- Files modified: [JobSchedulerService.cs](DiunaBI.Infrastructure/Services/JobSchedulerService.cs)
- Status: Race condition resolved, duplicate job creation prevented
---
**Blazor Server Reconnection UI Customization (Dec 8, 2025):**
-**Custom Reconnection Modal** - Replaced default Blazor "Rejoin failed..." dialog with custom-styled modal
-**Theme-Matched Styling** - Changed loader and button colors from blue to app's primary red (#e7163d) matching navbar
-**Timer with Elapsed Seconds** - Added real-time timer showing elapsed reconnection time (0s, 1s, 2s...)
-**CSS Classes Integration** - Used Blazor's built-in `.components-reconnect-show/failed/rejected` classes for state management
-**MutationObserver Timer** - JavaScript watches for CSS class changes to start/stop elapsed time counter
-**Professional Design** - Modal backdrop blur, spinner animation, red reload button with hover effects
- Files modified: [App.razor](DiunaBI.UI.Web/Components/App.razor), [app.css](DiunaBI.UI.Web/wwwroot/app.css)
- Files created: [reconnect.js](DiunaBI.UI.Web/wwwroot/js/reconnect.js)
- Status: Blazor reconnection UI now matches app theme with timer indicator
**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
-**Three Endpoints Fixed** - Applied fix to all job scheduling endpoints:
- `POST /jobs/schedule` - Schedule all jobs (imports + processes)
- `POST /jobs/schedule/imports` - Schedule import jobs only
- `POST /jobs/schedule/processes` - Schedule process jobs only
- Root cause: Controller-level `[Authorize]` attribute required JWT Bearer auth for all endpoints, blocking API key authentication
- Solution: Add `[AllowAnonymous]` to allow `[ApiKeyAuth]` filter to validate X-API-Key header
- Files modified: [JobsController.cs](DiunaBI.API/Controllers/JobsController.cs)
- Status: Cron jobs can now authenticate with API key via X-API-Key header
**SignalR Authentication Token Flow Fix (Dec 6, 2025):**
-**TokenProvider Population** - Fixed `TokenProvider.Token` never being set with JWT, causing 401 Unauthorized on SignalR connections
-**AuthService Token Management** - Injected `TokenProvider` into `AuthService` and set token in 3 key places:
- `ValidateWithBackendAsync()` - on fresh Google login
- `CheckAuthenticationAsync()` - on session restore from localStorage
- `ClearAuthenticationAsync()` - clear token on logout
-**SignalR Initialization Timing** - Moved SignalR initialization from `MainLayout.OnInitializedAsync` to after authentication completes
-**Event-Driven Architecture** - `MainLayout` now subscribes to `AuthenticationStateChanged` event to initialize SignalR when user authenticates
-**Session Restore Support** - `CheckAuthenticationAsync()` now fires `AuthenticationStateChanged` event to initialize SignalR on page refresh
- Root cause: SignalR was initialized before authentication, so JWT token was empty during connection setup
- Solution: Initialize SignalR only after token is available via event subscription
- Files modified: [AuthService.cs](DiunaBI.UI.Shared/Services/AuthService.cs), [MainLayout.razor](DiunaBI.UI.Shared/Components/Layout/MainLayout.razor)
- Status: SignalR authentication working for both fresh login and restored sessions
**SignalR Authentication DI Fix (Dec 6, 2025):**
-**TokenProvider Registration** - Added missing `TokenProvider` service registration in DI container
-**EntityChangeHubService Scope Fix** - Changed from singleton to scoped to support user-specific JWT tokens
-**Bug Fix** - Resolved `InvalidOperationException` preventing app from starting after SignalR authentication was added
- Root cause: Singleton service (`EntityChangeHubService`) cannot depend on scoped service (`TokenProvider`) in DI
- Solution: Made `EntityChangeHubService` scoped so each user session has its own authenticated SignalR connection
- Files modified: [ServiceCollectionExtensions.cs](DiunaBI.UI.Shared/Extensions/ServiceCollectionExtensions.cs)
---
**Security Audit & Hardening (Dec 5, 2025):**
-**JWT Token Validation** - Enabled issuer/audience validation in [Program.cs](DiunaBI.API/Program.cs), fixed config key mismatch in [JwtTokenService.cs](DiunaBI.API/Services/JwtTokenService.cs)
-**API Key Security** - Created [ApiKeyAuthAttribute.cs](DiunaBI.API/Attributes/ApiKeyAuthAttribute.cs) with X-API-Key header auth, constant-time comparison
-**Job Endpoints** - Migrated 3 job scheduling endpoints in [JobsController.cs](DiunaBI.API/Controllers/JobsController.cs) from URL-based to header-based API keys
-**Stack Trace Exposure** - Fixed 20 instances across 3 controllers ([JobsController.cs](DiunaBI.API/Controllers/JobsController.cs), [LayersController.cs](DiunaBI.API/Controllers/LayersController.cs), [DataInboxController.cs](DiunaBI.API/Controllers/DataInboxController.cs)) - now returns generic error messages
-**SignalR Authentication** - Added [Authorize] to [EntityChangeHub.cs](DiunaBI.API/Hubs/EntityChangeHub.cs), configured JWT token in [EntityChangeHubService.cs](DiunaBI.UI.Shared/Services/EntityChangeHubService.cs)
-**Rate Limiting** - Implemented ASP.NET Core rate limiting: 100 req/min general, 10 req/min auth in [Program.cs](DiunaBI.API/Program.cs)
-**Security Headers** - Added XSS, clickjacking, MIME sniffing protection middleware in [Program.cs](DiunaBI.API/Program.cs)
-**Input Validation** - Added pagination limits (1-1000) to GetAll endpoints in 3 controllers
-**User Enumeration** - Fixed generic auth error in [GoogleAuthService.cs](DiunaBI.API/Services/GoogleAuthService.cs)
-**Sensitive Data Logging** - Made conditional on development only in [Program.cs](DiunaBI.API/Program.cs)
-**Base64 Size Limit** - Added 10MB limit to DataInbox in [DataInboxController.cs](DiunaBI.API/Controllers/DataInboxController.cs)
- Files modified: 12 files (API: Program.cs, 4 controllers, 3 services, 1 hub, 1 new attribute; UI: EntityChangeHubService.cs, ServiceCollectionExtensions.cs)
- Security status: 5/5 CRITICAL fixed, 3/3 HIGH fixed, 4/4 MEDIUM fixed
**Seq Removal - Logging Cleanup (Dec 5, 2025):** **Seq Removal - Logging Cleanup (Dec 5, 2025):**
- ✅ Removed Seq logging sink to eliminate commercial licensing concerns - ✅ Removed Seq logging sink to eliminate commercial licensing concerns
- ✅ Removed `Serilog.Sinks.Seq` NuGet package from DiunaBI.API.csproj - ✅ Removed `Serilog.Sinks.Seq` NuGet package from DiunaBI.API.csproj

7
.gitignore vendored
View File

@@ -563,3 +563,10 @@ coverage/
## ##
tmp/ tmp/
temp/ temp/
##
## LocalDB Development Files
##
DevTools/LocalDB/backups/*.bak
DevTools/LocalDB/backups/*.bacpac
DevTools/LocalDB/data/

View File

@@ -1,7 +1,9 @@
DECLARE @JustForDebug TINYINT = 0; DECLARE @JustForDebug TINYINT = 0;
-- FIX DATAINBOX!
-- SETUP VARIABLES -- SETUP VARIABLES
DECLARE @Year INT = 2024; DECLARE @Year INT = 2025;
DECLARE @Type NVARCHAR(5) = 'B3'; DECLARE @Type NVARCHAR(5) = 'B3';
DECLARE @StartDate NVARCHAR(10) = '2025.01.02'; DECLARE @StartDate NVARCHAR(10) = '2025.01.02';
DECLARE @EndDate NVARCHAR(10) = '2026.12.31' DECLARE @EndDate NVARCHAR(10) = '2026.12.31'
@@ -22,7 +24,7 @@ SET @Plugin =
DECLARE @DataInboxName NVARCHAR(100); DECLARE @DataInboxName NVARCHAR(100);
SET @DataInboxName = SET @DataInboxName =
CASE @Type CASE @Type
WHEN 'B3' THEN 'B3_2024' WHEN 'B3' THEN 'P2_2025'
ELSE NULL -- If @Type doesn't match, set it to NULL ELSE NULL -- If @Type doesn't match, set it to NULL
END; END;

View File

@@ -1,7 +1,7 @@
DECLARE @JustForDebug TINYINT = 0; DECLARE @JustForDebug TINYINT = 0;
-- SETUP VARIABLES -- SETUP VARIABLES
DECLARE @Year INT = 2024; DECLARE @Year INT = 2025;
DECLARE @Number INT = (SELECT COUNT(id) + 1 FROM [DiunaBI-PedrolloPL].[dbo].[Layers]); DECLARE @Number INT = (SELECT COUNT(id) + 1 FROM [DiunaBI-PedrolloPL].[dbo].[Layers]);
DECLARE @CurrentTimestamp NVARCHAR(14) = FORMAT(GETDATE(), 'yyyyMMddHHmm'); DECLARE @CurrentTimestamp NVARCHAR(14) = FORMAT(GETDATE(), 'yyyyMMddHHmm');
@@ -56,3 +56,16 @@ VALUES ((SELECT NEWID()), 'Plugin', 'PedrolloPL.Process.P2', GETDATE(), GETDATE(
INSERT INTO [DiunaBI-PedrolloPL].[dbo].[Records] INSERT INTO [DiunaBI-PedrolloPL].[dbo].[Records]
([Id], [Code], [Desc1], [CreatedAt], [ModifiedAt], [CreatedById], [ModifiedById], [IsDeleted], [LayerId]) ([Id], [Code], [Desc1], [CreatedAt], [ModifiedAt], [CreatedById], [ModifiedById], [IsDeleted], [LayerId])
VALUES ((SELECT NEWID()), 'Priority', '110', GETDATE(), GETDATE(), '117be4f0-b5d1-41a1-a962-39dc30cce368', '117be4f0-b5d1-41a1-a962-39dc30cce368', 0, @LayerId); VALUES ((SELECT NEWID()), 'Priority', '110', GETDATE(), GETDATE(), '117be4f0-b5d1-41a1-a962-39dc30cce368', '117be4f0-b5d1-41a1-a962-39dc30cce368', 0, @LayerId);
--
INSERT INTO [DiunaBI-PedrolloPL].[dbo].[Records]
([Id], [Code], [Desc1], [CreatedAt], [ModifiedAt], [CreatedById], [ModifiedById], [IsDeleted], [LayerId])
VALUES ((SELECT NEWID()), 'GoogleSheetId', '1jI-3QrlBADm5slEl2Balf29cKmHwkYi4pboaHY-gRqc', GETDATE(), GETDATE(), '117be4f0-b5d1-41a1-a962-39dc30cce368', '117be4f0-b5d1-41a1-a962-39dc30cce368', 0, @LayerId);
INSERT INTO [DiunaBI-PedrolloPL].[dbo].[Records]
([Id], [Code], [Desc1], [CreatedAt], [ModifiedAt], [CreatedById], [ModifiedById], [IsDeleted], [LayerId])
VALUES ((SELECT NEWID()), 'GoogleSheetTab', 'P2_Export_DiunaBI', GETDATE(), GETDATE(), '117be4f0-b5d1-41a1-a962-39dc30cce368', '117be4f0-b5d1-41a1-a962-39dc30cce368', 0, @LayerId);
INSERT INTO [DiunaBI-PedrolloPL].[dbo].[Records]
([Id], [Code], [Desc1], [CreatedAt], [ModifiedAt], [CreatedById], [ModifiedById], [IsDeleted], [LayerId])
VALUES ((SELECT NEWID()), 'GoogleSheetRange', 'C32:O48', GETDATE(), GETDATE(), '117be4f0-b5d1-41a1-a962-39dc30cce368', '117be4f0-b5d1-41a1-a962-39dc30cce368', 0, @LayerId);

View File

@@ -2,6 +2,7 @@ using DiunaBI.API.Services;
using DiunaBI.Domain.Entities; using DiunaBI.Domain.Entities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace DiunaBI.API.Controllers; namespace DiunaBI.API.Controllers;
@@ -15,6 +16,7 @@ public class AuthController(
: ControllerBase : ControllerBase
{ {
[HttpPost("apiToken")] [HttpPost("apiToken")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> ApiToken([FromBody] string idToken) public async Task<IActionResult> ApiToken([FromBody] string idToken)
{ {
try try

View File

@@ -64,11 +64,21 @@ public class DataInboxController : Controller
} }
// check if datainbox.data is base64 encoded value // check if datainbox.data is base64 encoded value
if (!string.IsNullOrEmpty(dataInbox.Data) && !IsBase64String(dataInbox.Data)) if (!string.IsNullOrEmpty(dataInbox.Data))
{
// Limit data size to 10MB to prevent DoS
if (dataInbox.Data.Length > 10_000_000)
{
_logger.LogWarning("DataInbox: Data too large for source {Source}, size {Size}", dataInbox.Source, dataInbox.Data.Length);
return BadRequest("Data too large (max 10MB)");
}
if (!IsBase64String(dataInbox.Data))
{ {
_logger.LogWarning("DataInbox: Invalid data format - not base64 encoded for source {Source}", dataInbox.Source); _logger.LogWarning("DataInbox: Invalid data format - not base64 encoded for source {Source}", dataInbox.Source);
return BadRequest("Invalid data format - not base64 encoded"); return BadRequest("Invalid data format - not base64 encoded");
} }
}
dataInbox.Id = Guid.NewGuid(); dataInbox.Id = Guid.NewGuid();
dataInbox.CreatedAt = DateTime.UtcNow; dataInbox.CreatedAt = DateTime.UtcNow;
@@ -97,6 +107,16 @@ public class DataInboxController : Controller
{ {
try try
{ {
// Validate pagination parameters
if (limit <= 0 || limit > 1000)
{
return BadRequest("Limit must be between 1 and 1000");
}
if (start < 0)
{
return BadRequest("Start must be non-negative");
}
var query = _db.DataInbox.AsQueryable(); var query = _db.DataInbox.AsQueryable();
if (!string.IsNullOrEmpty(search)) if (!string.IsNullOrEmpty(search))

View File

@@ -36,17 +36,27 @@ public class JobsController : Controller
public async Task<IActionResult> GetAll( public async Task<IActionResult> GetAll(
[FromQuery] int start = 0, [FromQuery] int start = 0,
[FromQuery] int limit = 50, [FromQuery] int limit = 50,
[FromQuery] JobStatus? status = null, [FromQuery] List<JobStatus>? statuses = null,
[FromQuery] JobType? jobType = null, [FromQuery] JobType? jobType = null,
[FromQuery] Guid? layerId = null) [FromQuery] Guid? layerId = null)
{ {
try try
{ {
// Validate pagination parameters
if (limit <= 0 || limit > 1000)
{
return BadRequest("Limit must be between 1 and 1000");
}
if (start < 0)
{
return BadRequest("Start must be non-negative");
}
var query = _db.QueueJobs.AsQueryable(); 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) if (jobType.HasValue)
@@ -61,8 +71,10 @@ public class JobsController : Controller
var totalCount = await query.CountAsync(); var totalCount = await query.CountAsync();
// Sort by: CreatedAt DESC (newest first), then Priority ASC (0=highest)
var items = await query var items = await query
.OrderByDescending(j => j.CreatedAt) .OrderByDescending(j => j.CreatedAt)
.ThenBy(j => j.Priority)
.Skip(start) .Skip(start)
.Take(limit) .Take(limit)
.AsNoTracking() .AsNoTracking()
@@ -115,6 +127,7 @@ public class JobsController : Controller
[HttpPost] [HttpPost]
[Route("schedule")] [Route("schedule")]
[AllowAnonymous] // Bypass controller-level [Authorize] to allow API key auth
[ApiKeyAuth] [ApiKeyAuth]
public async Task<IActionResult> ScheduleJobs([FromQuery] string? nameFilter = null) public async Task<IActionResult> ScheduleJobs([FromQuery] string? nameFilter = null)
{ {
@@ -140,6 +153,7 @@ public class JobsController : Controller
[HttpPost] [HttpPost]
[Route("schedule/imports")] [Route("schedule/imports")]
[AllowAnonymous] // Bypass controller-level [Authorize] to allow API key auth
[ApiKeyAuth] [ApiKeyAuth]
public async Task<IActionResult> ScheduleImportJobs([FromQuery] string? nameFilter = null) public async Task<IActionResult> ScheduleImportJobs([FromQuery] string? nameFilter = null)
{ {
@@ -165,6 +179,7 @@ public class JobsController : Controller
[HttpPost] [HttpPost]
[Route("schedule/processes")] [Route("schedule/processes")]
[AllowAnonymous] // Bypass controller-level [Authorize] to allow API key auth
[ApiKeyAuth] [ApiKeyAuth]
public async Task<IActionResult> ScheduleProcessJobs() public async Task<IActionResult> ScheduleProcessJobs()
{ {
@@ -188,6 +203,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] [HttpPost]
[Route("{id:guid}/retry")] [Route("{id:guid}/retry")]
public async Task<IActionResult> RetryJob(Guid id) public async Task<IActionResult> RetryJob(Guid id)
@@ -211,7 +299,8 @@ public class JobsController : Controller
job.Status = JobStatus.Pending; job.Status = JobStatus.Pending;
job.RetryCount = 0; job.RetryCount = 0;
job.LastError = null; job.LastError = null;
job.ModifiedAtUtc = DateTime.UtcNow; job.ModifiedAt = DateTime.UtcNow;
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -258,7 +347,8 @@ public class JobsController : Controller
job.Status = JobStatus.Failed; job.Status = JobStatus.Failed;
job.LastError = "Cancelled by user"; job.LastError = "Cancelled by user";
job.ModifiedAtUtc = DateTime.UtcNow; job.ModifiedAt = DateTime.UtcNow;
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -389,10 +479,9 @@ public class JobsController : Controller
MaxRetries = maxRetries, MaxRetries = maxRetries,
Status = JobStatus.Pending, Status = JobStatus.Pending,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CreatedAtUtc = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow,
ModifiedAtUtc = DateTime.UtcNow, CreatedById = DiunaBI.Domain.Entities.User.AutoImportUserId,
CreatedById = Guid.Empty, ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId
ModifiedById = Guid.Empty
}; };
_db.QueueJobs.Add(job); _db.QueueJobs.Add(job);

View File

@@ -48,6 +48,16 @@ public class LayersController : Controller
{ {
try try
{ {
// Validate pagination parameters
if (limit <= 0 || limit > 1000)
{
return BadRequest("Limit must be between 1 and 1000");
}
if (start < 0)
{
return BadRequest("Start must be non-negative");
}
var query = _db.Layers.Where(x => !x.IsDeleted); var query = _db.Layers.Where(x => !x.IsDeleted);
if (name != null) if (name != null)

View File

@@ -1,16 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
namespace DiunaBI.API.Hubs; namespace DiunaBI.API.Hubs;
/// <summary>
/// SignalR hub for broadcasting entity change notifications to authenticated clients.
/// Clients can only listen - broadcasting is done server-side by EntityChangeInterceptor.
/// </summary>
[Authorize]
public class EntityChangeHub : Hub public class EntityChangeHub : Hub
{ {
public async Task SendEntityChange(string module, string id, string operation) // No public methods - clients can only listen for "EntityChanged" events
{ // Broadcasting is handled server-side by EntityChangeInterceptor via IHubContext
await Clients.All.SendAsync("EntityChanged", new
{
module,
id,
operation
});
}
} }

View File

@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Threading.RateLimiting;
using DiunaBI.API.Hubs; using DiunaBI.API.Hubs;
using DiunaBI.API.Services; using DiunaBI.API.Services;
using DiunaBI.Infrastructure.Data; using DiunaBI.Infrastructure.Data;
@@ -37,7 +39,12 @@ builder.Services.AddSingleton<EntityChangeInterceptor>();
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) => builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{ {
options.UseSqlServer(connectionString, sqlOptions => sqlOptions.MigrationsAssembly("DiunaBI.Infrastructure")); options.UseSqlServer(connectionString, sqlOptions => sqlOptions.MigrationsAssembly("DiunaBI.Infrastructure"));
// Only log SQL parameters in development (may contain sensitive data)
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging(); options.EnableSensitiveDataLogging();
}
// Add EntityChangeInterceptor // Add EntityChangeInterceptor
var interceptor = serviceProvider.GetRequiredService<EntityChangeInterceptor>(); var interceptor = serviceProvider.GetRequiredService<EntityChangeInterceptor>();
@@ -67,6 +74,41 @@ builder.Services.AddCors(options =>
builder.Services.AddControllers(); builder.Services.AddControllers();
// Rate Limiting
builder.Services.AddRateLimiter(options =>
{
// Global API rate limit
options.AddFixedWindowLimiter("api", config =>
{
config.PermitLimit = 100;
config.Window = TimeSpan.FromMinutes(1);
config.QueueProcessingOrder = System.Threading.RateLimiting.QueueProcessingOrder.OldestFirst;
config.QueueLimit = 0; // No queueing
});
// Strict limit for authentication endpoint
options.AddFixedWindowLimiter("auth", config =>
{
config.PermitLimit = 10;
config.Window = TimeSpan.FromMinutes(1);
config.QueueProcessingOrder = System.Threading.RateLimiting.QueueProcessingOrder.OldestFirst;
config.QueueLimit = 0;
});
// Rejection response
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = 429; // Too Many Requests
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "Too many requests. Please try again later.",
retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)
? (double?)retryAfter.TotalSeconds
: (double?)null
}, cancellationToken: token);
};
});
// SignalR // SignalR
builder.Services.AddSignalR(); builder.Services.AddSignalR();
@@ -197,6 +239,18 @@ pluginManager.LoadPluginsFromDirectory(pluginsPath);
app.UseCors("CORSPolicy"); app.UseCors("CORSPolicy");
// Security Headers
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
await next();
});
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
@@ -254,8 +308,8 @@ app.Use(async (context, next) =>
app.MapControllers(); app.MapControllers();
// SignalR Hub // SignalR Hub - Requires JWT authentication
app.MapHub<EntityChangeHub>("/hubs/entitychanges"); app.MapHub<EntityChangeHub>("/hubs/entitychanges").RequireAuthorization();
app.MapGet("/health", () => Results.Ok(new { status = "OK", timestamp = DateTime.UtcNow })) app.MapGet("/health", () => Results.Ok(new { status = "OK", timestamp = DateTime.UtcNow }))
.AllowAnonymous(); .AllowAnonymous();

View File

@@ -36,7 +36,7 @@ public class GoogleAuthService(AppDbContext context, IConfiguration configuratio
if (user == null) if (user == null)
{ {
_logger.LogError("User not found in DiunaBI database: {Email}", payload.Email); _logger.LogError("User not found in DiunaBI database: {Email}", payload.Email);
return (false, null, "User not found in DiunaBI database"); return (false, null, "Authentication failed");
} }
user.UserName = payload.Name; user.UserName = payload.Name;

View File

@@ -12,6 +12,7 @@ public class QueueJob
public JobType JobType { get; set; } public JobType JobType { get; set; }
public int Priority { get; set; } = 0; // 0 = highest priority public int Priority { get; set; } = 0; // 0 = highest priority
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; } = 0; public int RetryCount { get; set; } = 0;
public int MaxRetries { get; set; } = 5; public int MaxRetries { get; set; } = 5;
public JobStatus Status { get; set; } = JobStatus.Pending; public JobStatus Status { get; set; } = JobStatus.Pending;
@@ -19,9 +20,7 @@ public class QueueJob
public DateTime? LastAttemptAt { get; set; } public DateTime? LastAttemptAt { get; set; }
public DateTime? CompletedAt { get; set; } public DateTime? CompletedAt { get; set; }
public Guid CreatedById { get; set; } public Guid CreatedById { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public Guid ModifiedById { get; set; } public Guid ModifiedById { get; set; }
public DateTime ModifiedAtUtc { get; set; } = DateTime.UtcNow;
} }
public enum JobType public enum JobType

View File

@@ -5,6 +5,11 @@ namespace DiunaBI.Domain.Entities;
public class User 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 #region Properties
public Guid Id { get; init; } public Guid Id { get; init; }
public string? Email { get; init; } public string? Email { get; init; }

View File

@@ -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.LastAttemptAt);
modelBuilder.Entity<QueueJob>().Property(x => x.CompletedAt); modelBuilder.Entity<QueueJob>().Property(x => x.CompletedAt);
modelBuilder.Entity<QueueJob>().Property(x => x.CreatedById).IsRequired(); 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.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 // Configure automatic timestamps for entities with CreatedAt/ModifiedAt
ConfigureTimestamps(modelBuilder); ConfigureTimestamps(modelBuilder);

View File

@@ -22,8 +22,6 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.68.0.3525" /> <PackageReference Include="Google.Apis.Sheets.v4" Version="1.68.0.3525" />
<PackageReference Include="Google.Apis.Drive.v3" Version="1.68.0.3490" /> <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>
<ItemGroup> <ItemGroup>

View 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
}
}
}

View File

@@ -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));
}
}
}

View File

@@ -49,7 +49,7 @@ namespace DiunaBI.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("DataInbox", (string)null); b.ToTable("DataInbox");
}); });
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b => modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>
@@ -104,7 +104,7 @@ namespace DiunaBI.Infrastructure.Migrations
b.HasIndex("ModifiedById"); b.HasIndex("ModifiedById");
b.ToTable("Layers", (string)null); b.ToTable("Layers");
}); });
modelBuilder.Entity("DiunaBI.Domain.Entities.ProcessSource", b => modelBuilder.Entity("DiunaBI.Domain.Entities.ProcessSource", b =>
@@ -119,7 +119,7 @@ namespace DiunaBI.Infrastructure.Migrations
b.HasIndex("SourceId"); b.HasIndex("SourceId");
b.ToTable("ProcessSources", (string)null); b.ToTable("ProcessSources");
}); });
modelBuilder.Entity("DiunaBI.Domain.Entities.QueueJob", b => modelBuilder.Entity("DiunaBI.Domain.Entities.QueueJob", b =>
@@ -136,9 +136,6 @@ namespace DiunaBI.Infrastructure.Migrations
.HasColumnType("datetime2") .HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()"); .HasDefaultValueSql("GETUTCDATE()");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("datetime2");
b.Property<Guid>("CreatedById") b.Property<Guid>("CreatedById")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
@@ -163,8 +160,10 @@ namespace DiunaBI.Infrastructure.Migrations
b.Property<int>("MaxRetries") b.Property<int>("MaxRetries")
.HasColumnType("int"); .HasColumnType("int");
b.Property<DateTime>("ModifiedAtUtc") b.Property<DateTime>("ModifiedAt")
.HasColumnType("datetime2"); .ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<Guid>("ModifiedById") b.Property<Guid>("ModifiedById")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
@@ -185,7 +184,7 @@ namespace DiunaBI.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("QueueJobs", (string)null); b.ToTable("QueueJobs");
}); });
modelBuilder.Entity("DiunaBI.Domain.Entities.Record", b => modelBuilder.Entity("DiunaBI.Domain.Entities.Record", b =>
@@ -329,7 +328,7 @@ namespace DiunaBI.Infrastructure.Migrations
b.HasIndex("ModifiedById"); b.HasIndex("ModifiedById");
b.ToTable("Records", (string)null); b.ToTable("Records");
}); });
modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b => modelBuilder.Entity("DiunaBI.Domain.Entities.RecordHistory", b =>
@@ -378,7 +377,7 @@ namespace DiunaBI.Infrastructure.Migrations
b.HasIndex("RecordId", "ChangedAt"); b.HasIndex("RecordId", "ChangedAt");
b.ToTable("RecordHistory", (string)null); b.ToTable("RecordHistory");
}); });
modelBuilder.Entity("DiunaBI.Domain.Entities.User", b => modelBuilder.Entity("DiunaBI.Domain.Entities.User", b =>
@@ -402,7 +401,7 @@ namespace DiunaBI.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Users", (string)null); b.ToTable("Users");
}); });
modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b => modelBuilder.Entity("DiunaBI.Domain.Entities.Layer", b =>

View File

@@ -40,6 +40,7 @@ public class JobSchedulerService
_logger.LogInformation("JobScheduler: Found {Count} import workers to schedule", importWorkers.Count); _logger.LogInformation("JobScheduler: Found {Count} import workers to schedule", importWorkers.Count);
var jobsCreated = 0; var jobsCreated = 0;
var scheduledLayerIds = new HashSet<Guid>(); // Track LayerIds scheduled in this batch
foreach (var worker in importWorkers) foreach (var worker in importWorkers)
{ {
@@ -61,7 +62,15 @@ public class JobSchedulerService
var maxRetriesStr = worker.Records?.FirstOrDefault(r => r.Code == "MaxRetries")?.Desc1; var maxRetriesStr = worker.Records?.FirstOrDefault(r => r.Code == "MaxRetries")?.Desc1;
var maxRetries = int.TryParse(maxRetriesStr, out var mr) ? mr : 3; var maxRetries = int.TryParse(maxRetriesStr, out var mr) ? mr : 3;
// Check if there's already a pending/running job for this layer // Check in-memory: already scheduled in this batch?
if (scheduledLayerIds.Contains(worker.Id))
{
_logger.LogDebug("JobScheduler: Job already scheduled in this batch for {LayerName} ({LayerId})",
worker.Name, worker.Id);
continue;
}
// Check if there's already a pending/running job for this layer in database
var existingJob = await _db.QueueJobs var existingJob = await _db.QueueJobs
.Where(j => j.LayerId == worker.Id && .Where(j => j.LayerId == worker.Id &&
(j.Status == JobStatus.Pending || j.Status == JobStatus.Running)) (j.Status == JobStatus.Pending || j.Status == JobStatus.Running))
@@ -85,13 +94,13 @@ public class JobSchedulerService
MaxRetries = maxRetries, MaxRetries = maxRetries,
Status = JobStatus.Pending, Status = JobStatus.Pending,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CreatedAtUtc = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow,
ModifiedAtUtc = DateTime.UtcNow, CreatedById = DiunaBI.Domain.Entities.User.AutoImportUserId,
CreatedById = Guid.Empty, // System user ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId
ModifiedById = Guid.Empty
}; };
_db.QueueJobs.Add(job); _db.QueueJobs.Add(job);
scheduledLayerIds.Add(worker.Id); // Track that we've scheduled this layer
jobsCreated++; jobsCreated++;
_logger.LogInformation("JobScheduler: Created import job for {LayerName} ({LayerId}) with priority {Priority}", _logger.LogInformation("JobScheduler: Created import job for {LayerName} ({LayerId}) with priority {Priority}",
@@ -130,6 +139,7 @@ public class JobSchedulerService
_logger.LogInformation("JobScheduler: Found {Count} process workers to schedule", processWorkers.Count); _logger.LogInformation("JobScheduler: Found {Count} process workers to schedule", processWorkers.Count);
var jobsCreated = 0; var jobsCreated = 0;
var scheduledLayerIds = new HashSet<Guid>(); // Track LayerIds scheduled in this batch
foreach (var worker in processWorkers) foreach (var worker in processWorkers)
{ {
@@ -151,7 +161,15 @@ public class JobSchedulerService
var maxRetriesStr = worker.Records?.FirstOrDefault(r => r.Code == "MaxRetries")?.Desc1; var maxRetriesStr = worker.Records?.FirstOrDefault(r => r.Code == "MaxRetries")?.Desc1;
var maxRetries = int.TryParse(maxRetriesStr, out var mr) ? mr : 3; var maxRetries = int.TryParse(maxRetriesStr, out var mr) ? mr : 3;
// Check if there's already a pending/running job for this layer // Check in-memory: already scheduled in this batch?
if (scheduledLayerIds.Contains(worker.Id))
{
_logger.LogDebug("JobScheduler: Job already scheduled in this batch for {LayerName} ({LayerId})",
worker.Name, worker.Id);
continue;
}
// Check if there's already a pending/running job for this layer in database
var existingJob = await _db.QueueJobs var existingJob = await _db.QueueJobs
.Where(j => j.LayerId == worker.Id && .Where(j => j.LayerId == worker.Id &&
(j.Status == JobStatus.Pending || j.Status == JobStatus.Running)) (j.Status == JobStatus.Pending || j.Status == JobStatus.Running))
@@ -175,13 +193,13 @@ public class JobSchedulerService
MaxRetries = maxRetries, MaxRetries = maxRetries,
Status = JobStatus.Pending, Status = JobStatus.Pending,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CreatedAtUtc = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow,
ModifiedAtUtc = DateTime.UtcNow, CreatedById = DiunaBI.Domain.Entities.User.AutoImportUserId,
CreatedById = Guid.Empty, ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId
ModifiedById = Guid.Empty
}; };
_db.QueueJobs.Add(job); _db.QueueJobs.Add(job);
scheduledLayerIds.Add(worker.Id); // Track that we've scheduled this layer
jobsCreated++; jobsCreated++;
_logger.LogInformation("JobScheduler: Created process job for {LayerName} ({LayerId}) with priority {Priority}", _logger.LogInformation("JobScheduler: Created process job for {LayerName} ({LayerId}) with priority {Priority}",

View File

@@ -11,7 +11,7 @@ public class JobWorkerService : BackgroundService
{ {
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly ILogger<JobWorkerService> _logger; private readonly ILogger<JobWorkerService> _logger;
private readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(10); private readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(5);
private readonly TimeSpan _rateLimitDelay = TimeSpan.FromSeconds(5); private readonly TimeSpan _rateLimitDelay = TimeSpan.FromSeconds(5);
public JobWorkerService(IServiceProvider serviceProvider, ILogger<JobWorkerService> logger) public JobWorkerService(IServiceProvider serviceProvider, ILogger<JobWorkerService> logger)
@@ -66,7 +66,8 @@ public class JobWorkerService : BackgroundService
// Mark job as running // Mark job as running
job.Status = JobStatus.Running; job.Status = JobStatus.Running;
job.LastAttemptAt = DateTime.UtcNow; job.LastAttemptAt = DateTime.UtcNow;
job.ModifiedAtUtc = DateTime.UtcNow; job.ModifiedAt = DateTime.UtcNow;
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
await db.SaveChangesAsync(stoppingToken); await db.SaveChangesAsync(stoppingToken);
try try
@@ -114,7 +115,8 @@ public class JobWorkerService : BackgroundService
job.Status = JobStatus.Completed; job.Status = JobStatus.Completed;
job.CompletedAt = DateTime.UtcNow; job.CompletedAt = DateTime.UtcNow;
job.LastError = null; 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); _logger.LogInformation("JobWorker: Job {JobId} completed successfully", job.Id);
@@ -131,7 +133,8 @@ public class JobWorkerService : BackgroundService
// Capture full error details including inner exceptions // Capture full error details including inner exceptions
job.LastError = GetFullErrorMessage(ex); job.LastError = GetFullErrorMessage(ex);
job.ModifiedAtUtc = DateTime.UtcNow; job.ModifiedAt = DateTime.UtcNow;
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
if (job.RetryCount >= job.MaxRetries) if (job.RetryCount >= job.MaxRetries)
{ {
@@ -157,7 +160,8 @@ public class JobWorkerService : BackgroundService
// Increment retry count for next attempt // Increment retry count for next attempt
job.RetryCount++; job.RetryCount++;
job.ModifiedAtUtc = DateTime.UtcNow; job.ModifiedAt = DateTime.UtcNow;
job.ModifiedById = DiunaBI.Domain.Entities.User.AutoImportUserId;
} }
} }
finally finally

View File

@@ -2,36 +2,31 @@
@using DiunaBI.UI.Shared.Services @using DiunaBI.UI.Shared.Services
@inject AppConfig AppConfig @inject AppConfig AppConfig
@inject EntityChangeHubService HubService @inject EntityChangeHubService HubService
@inject AuthService AuthService
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable
<AuthGuard> <AuthGuard>
<MudThemeProvider Theme="_theme"/> <MudThemeProvider Theme="_theme" />
<MudPopoverProvider/> <MudPopoverProvider />
<MudDialogProvider/> <MudDialogProvider />
<MudSnackbarProvider/> <MudSnackbarProvider />
<MudLayout> <MudLayout>
<MudBreakpointProvider OnBreakpointChanged="OnBreakpointChanged"></MudBreakpointProvider> <MudBreakpointProvider OnBreakpointChanged="OnBreakpointChanged"></MudBreakpointProvider>
<MudAppBar Elevation="0"> <MudAppBar Elevation="0">
<MudIconButton <MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
Icon="@Icons.Material.Filled.Menu" OnClick="ToggleDrawer" Class="mud-hidden-md-up" />
Color="Color.Inherit" <MudSpacer />
Edge="Edge.Start"
OnClick="ToggleDrawer"
Class="mud-hidden-md-up"/>
<MudSpacer/>
<MudText Typo="Typo.h6">@AppConfig.AppName</MudText> <MudText Typo="Typo.h6">@AppConfig.AppName</MudText>
</MudAppBar> </MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" <MudDrawer @bind-Open="_drawerOpen" Anchor="Anchor.Start" Variant="@_drawerVariant" Elevation="1"
Anchor="Anchor.Start" ClipMode="DrawerClipMode.Always" Class="mud-width-250">
Variant="@_drawerVariant"
Elevation="1"
ClipMode="DrawerClipMode.Always"
Class="mud-width-250">
<div class="nav-logo" style="text-align: center; padding: 20px;"> <div class="nav-logo" style="text-align: center; padding: 20px;">
<a href="https://www.diunabi.com" target="_blank"> <a href="https://www.diunabi.com" target="_blank">
<img src="_content/DiunaBI.UI.Shared/images/logo.png" alt="DiunaBI" style="max-width: 180px; height: auto;" /> <img src="_content/DiunaBI.UI.Shared/images/logo.png" alt="DiunaBI"
style="max-width: 180px; height: auto;" />
</a> </a>
</div> </div>
<MudNavMenu> <MudNavMenu>
@@ -40,6 +35,10 @@
<MudNavLink Href="/datainbox" Icon="@Icons.Material.Filled.Inbox">Data Inbox</MudNavLink> <MudNavLink Href="/datainbox" Icon="@Icons.Material.Filled.Inbox">Data Inbox</MudNavLink>
<MudNavLink Href="/jobs" Icon="@Icons.Material.Filled.WorkHistory">Jobs</MudNavLink> <MudNavLink Href="/jobs" Icon="@Icons.Material.Filled.WorkHistory">Jobs</MudNavLink>
</MudNavMenu> </MudNavMenu>
<div class="nav-logo" style="text-align: center; padding: 20px;">
<img src="_content/DiunaBI.UI.Shared/images/clients/@AppConfig.ClientLogo" alt="DiunaBI"
style="max-width: 180px; height: auto;" />
</div>
</MudDrawer> </MudDrawer>
<MudMainContent> <MudMainContent>
@@ -55,11 +54,31 @@
private bool _drawerOpen = true; private bool _drawerOpen = true;
private DrawerVariant _drawerVariant = DrawerVariant.Persistent; private DrawerVariant _drawerVariant = DrawerVariant.Persistent;
protected override async Task OnInitializedAsync() protected override void OnInitialized()
{ {
// Initialize SignalR connection when layout loads // Subscribe to authentication state changes
AuthService.AuthenticationStateChanged += OnAuthenticationStateChanged;
// If already authenticated (e.g., from restored session), initialize SignalR
if (AuthService.IsAuthenticated)
{
_ = HubService.InitializeAsync();
}
}
private async void OnAuthenticationStateChanged(bool isAuthenticated)
{
if (isAuthenticated)
{
Console.WriteLine("🔐 MainLayout: User authenticated, initializing SignalR...");
await HubService.InitializeAsync(); await HubService.InitializeAsync();
} }
}
public void Dispose()
{
AuthService.AuthenticationStateChanged -= OnAuthenticationStateChanged;
}
private MudTheme _theme = new MudTheme() private MudTheme _theme = new MudTheme()
{ {

View File

@@ -39,22 +39,23 @@ public static class ServiceCollectionExtensions
}); });
// Services // Services
services.AddScoped<TokenProvider>();
services.AddScoped<AuthService>(); services.AddScoped<AuthService>();
services.AddScoped<LayerService>(); services.AddScoped<LayerService>();
services.AddScoped<DataInboxService>(); services.AddScoped<DataInboxService>();
services.AddScoped<JobService>(); services.AddScoped<JobService>();
services.AddScoped<DateTimeHelper>();
// Filter state services (scoped to maintain state during user session) // Filter state services (scoped to maintain state during user session)
services.AddScoped<LayerFilterStateService>(); services.AddScoped<LayerFilterStateService>();
services.AddScoped<DataInboxFilterStateService>(); services.AddScoped<DataInboxFilterStateService>();
// SignalR Hub Service (singleton for global connection shared across all users) // SignalR Hub Service (scoped per user session for authenticated connections)
services.AddSingleton<EntityChangeHubService>(sp => services.AddScoped(sp =>
{ {
// For singleton, we can't inject scoped services directly
// We'll get them from the service provider when needed
var logger = sp.GetRequiredService<ILogger<EntityChangeHubService>>(); var logger = sp.GetRequiredService<ILogger<EntityChangeHubService>>();
return new EntityChangeHubService(apiBaseUrl, sp, logger); var tokenProvider = sp.GetRequiredService<TokenProvider>();
return new EntityChangeHubService(apiBaseUrl, sp, logger, tokenProvider);
}); });
return services; return services;

View File

@@ -1,6 +1,7 @@
@page "/datainbox" @page "/datainbox"
@using MudBlazor.Internal @using MudBlazor.Internal
@using DiunaBI.Application.DTOModels @using DiunaBI.Application.DTOModels
@implements IDisposable
<PageTitle>Data Inbox</PageTitle> <PageTitle>Data Inbox</PageTitle>
@@ -52,7 +53,7 @@
<RowTemplate Context="row"> <RowTemplate Context="row">
<MudTd DataLabel="Name"><div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true">@row.Name</div></MudTd> <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="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> </RowTemplate>
<NoRecordsContent> <NoRecordsContent>
<MudText>No data inbox items to display</MudText> <MudText>No data inbox items to display</MudText>

View File

@@ -8,13 +8,15 @@ using Microsoft.JSInterop;
namespace DiunaBI.UI.Shared.Pages.DataInbox; namespace DiunaBI.UI.Shared.Pages.DataInbox;
public partial class Index : ComponentBase public partial class Index : ComponentBase, IDisposable
{ {
[Inject] private DataInboxService DataInboxService { get; set; } = default!; [Inject] private DataInboxService DataInboxService { get; set; } = default!;
[Inject] private EntityChangeHubService HubService { get; set; } = default!;
[Inject] private ISnackbar Snackbar { get; set; } = default!; [Inject] private ISnackbar Snackbar { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!;
[Inject] private DataInboxFilterStateService FilterStateService { get; set; } = default!; [Inject] private DataInboxFilterStateService FilterStateService { get; set; } = default!;
[Inject] private IJSRuntime JSRuntime { get; set; } = default!; [Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private DateTimeHelper DateTimeHelper { get; set; } = default!;
private PagedResult<DataInboxDto> dataInbox = new(); private PagedResult<DataInboxDto> dataInbox = new();
@@ -23,8 +25,25 @@ public partial class Index : ComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await DateTimeHelper.InitializeAsync();
filterRequest = FilterStateService.FilterRequest; filterRequest = FilterStateService.FilterRequest;
await LoadDataInbox(); await LoadDataInbox();
// Subscribe to SignalR entity changes
HubService.EntityChanged += OnEntityChanged;
}
private async void OnEntityChanged(string module, string id, string operation)
{
// Only react if it's a DataInbox change
if (module.Equals("DataInbox", StringComparison.OrdinalIgnoreCase))
{
await InvokeAsync(async () =>
{
await LoadDataInbox();
StateHasChanged();
});
}
} }
private async Task LoadDataInbox() private async Task LoadDataInbox()
@@ -75,4 +94,9 @@ public partial class Index : ComponentBase
var url = NavigationManager.ToAbsoluteUri($"/datainbox/{dataInboxItem.Id}").ToString(); var url = NavigationManager.ToAbsoluteUri($"/datainbox/{dataInboxItem.Id}").ToString();
await JSRuntime.InvokeVoidAsync("open", url, "_blank"); await JSRuntime.InvokeVoidAsync("open", url, "_blank");
} }
public void Dispose()
{
HubService.EntityChanged -= OnEntityChanged;
}
} }

View File

@@ -6,6 +6,7 @@
@inject EntityChangeHubService HubService @inject EntityChangeHubService HubService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject DateTimeHelper DateTimeHelper
@implements IDisposable @implements IDisposable
<MudCard> <MudCard>
@@ -92,14 +93,14 @@
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <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" Label="Created At"
Variant="Variant.Outlined" Variant="Variant.Outlined"
ReadOnly="true" ReadOnly="true"
FullWidth="true"/> FullWidth="true"/>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <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" Label="Last Attempt At"
Variant="Variant.Outlined" Variant="Variant.Outlined"
ReadOnly="true" ReadOnly="true"
@@ -107,7 +108,7 @@
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <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" Label="Completed At"
Variant="Variant.Outlined" Variant="Variant.Outlined"
ReadOnly="true" ReadOnly="true"
@@ -161,6 +162,7 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await DateTimeHelper.InitializeAsync();
await LoadJob(); await LoadJob();
// Subscribe to SignalR entity changes // Subscribe to SignalR entity changes

View File

@@ -12,25 +12,28 @@
Expanded="true"> Expanded="true">
<MudGrid AlignItems="Center"> <MudGrid AlignItems="Center">
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudSelect T="JobStatus?" <MudSelect T="JobStatus"
@bind-Value="selectedStatus" SelectedValues="selectedStatuses"
Label="Status" Label="Status"
Placeholder="All statuses" Placeholder="All statuses"
MultiSelection="true"
Clearable="true" Clearable="true"
SelectedValuesChanged="OnStatusFilterChanged"
OnClearButtonClick="OnStatusClear"> OnClearButtonClick="OnStatusClear">
@foreach (JobStatus status in Enum.GetValues(typeof(JobStatus))) @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> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudSelect T="JobType?" <MudSelect T="JobType?"
@bind-Value="selectedJobType" Value="selectedJobType"
Label="Job Type" Label="Job Type"
Placeholder="All types" Placeholder="All types"
Clearable="true" Clearable="true"
ValueChanged="OnJobTypeFilterChanged"
OnClearButtonClick="OnJobTypeClear"> OnClearButtonClick="OnJobTypeClear">
@foreach (JobType type in Enum.GetValues(typeof(JobType))) @foreach (JobType type in Enum.GetValues(typeof(JobType)))
{ {
@@ -39,12 +42,33 @@
</MudSelect> </MudSelect>
</MudItem> </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">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" <MudMenu Icon="@Icons.Material.Filled.PlayArrow"
OnClick="LoadJobs" Label="Schedule Jobs"
Color="Color.Primary" Variant="Variant.Filled"
Color="Color.Success"
Size="Size.Medium" Size="Size.Medium"
Title="Refresh"/> 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.Clear" <MudIconButton Icon="@Icons.Material.Filled.Clear"
OnClick="ClearFilters" OnClick="ClearFilters"
Color="Color.Default" Color="Color.Default"
@@ -108,12 +132,12 @@
</MudTd> </MudTd>
<MudTd DataLabel="Created"> <MudTd DataLabel="Created">
<div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true"> <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> </div>
</MudTd> </MudTd>
<MudTd DataLabel="Last Attempt"> <MudTd DataLabel="Last Attempt">
<div @oncontextmenu="@(async (e) => await OnRowRightClick(e, row))" @oncontextmenu:preventDefault="true"> <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> </div>
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>

View File

@@ -15,16 +15,18 @@ public partial class Index : ComponentBase, IDisposable
[Inject] private ISnackbar Snackbar { get; set; } = default!; [Inject] private ISnackbar Snackbar { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!;
[Inject] private IJSRuntime JSRuntime { get; set; } = default!; [Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private DateTimeHelper DateTimeHelper { get; set; } = default!;
private PagedResult<QueueJob> jobs = new(); private PagedResult<QueueJob> jobs = new();
private bool isLoading = false; private bool isLoading = false;
private int currentPage = 1; private int currentPage = 1;
private int pageSize = 50; private int pageSize = 50;
private JobStatus? selectedStatus = null; private IEnumerable<JobStatus> selectedStatuses = new HashSet<JobStatus>();
private JobType? selectedJobType = null; private JobType? selectedJobType = null;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await DateTimeHelper.InitializeAsync();
await LoadJobs(); await LoadJobs();
// Subscribe to SignalR entity changes // Subscribe to SignalR entity changes
@@ -60,7 +62,8 @@ public partial class Index : ComponentBase, IDisposable
try try
{ {
jobs = await JobService.GetJobsAsync(currentPage, pageSize, selectedStatus, selectedJobType); var statusList = selectedStatuses?.ToList();
jobs = await JobService.GetJobsAsync(currentPage, pageSize, statusList, selectedJobType);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -81,15 +84,29 @@ public partial class Index : ComponentBase, IDisposable
private async Task ClearFilters() private async Task ClearFilters()
{ {
selectedStatus = null; selectedStatuses = new HashSet<JobStatus>();
selectedJobType = null; selectedJobType = null;
currentPage = 1; currentPage = 1;
await LoadJobs(); 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() private async Task OnStatusClear()
{ {
selectedStatus = null; selectedStatuses = new HashSet<JobStatus>();
currentPage = 1; currentPage = 1;
await LoadJobs(); await LoadJobs();
} }
@@ -112,6 +129,41 @@ public partial class Index : ComponentBase, IDisposable
await JSRuntime.InvokeVoidAsync("open", url, "_blank"); 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) private Color GetStatusColor(JobStatus status)
{ {
return status switch return status switch

View File

@@ -2,6 +2,7 @@
@using DiunaBI.UI.Shared.Services @using DiunaBI.UI.Shared.Services
@using DiunaBI.Application.DTOModels @using DiunaBI.Application.DTOModels
@using MudBlazor @using MudBlazor
@implements IDisposable
<MudCard> <MudCard>
<MudCardHeader> <MudCardHeader>
@@ -58,7 +59,7 @@
} }
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudTextField Value="@layer.CreatedAt.ToString("g")" <MudTextField Value="@DateTimeHelper.FormatDateTime(layer.CreatedAt, "yyyy-MM-dd HH:mm")"
Label="Created" Label="Created"
Variant="Variant.Outlined" Variant="Variant.Outlined"
ReadOnly="true" ReadOnly="true"
@@ -67,7 +68,7 @@
AdornmentText="@(layer.CreatedBy?.Username ?? "")"/> AdornmentText="@(layer.CreatedBy?.Username ?? "")"/>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudTextField Value="@layer.ModifiedAt.ToString("g")" <MudTextField Value="@DateTimeHelper.FormatDateTime(layer.ModifiedAt, "yyyy-MM-dd HH:mm")"
Label="Modified" Label="Modified"
Variant="Variant.Outlined" Variant="Variant.Outlined"
ReadOnly="true" ReadOnly="true"
@@ -162,12 +163,14 @@
} }
</RowTemplate> </RowTemplate>
<FooterContent> <FooterContent>
<MudTd><b>Value1 sum</b></MudTd> @if (showSummary)
{
<MudTd><b>@totalSum.ToString("N2")</b></MudTd>
@foreach (var column in displayedColumns) @foreach (var column in displayedColumns)
{ {
@if (column == "Value1") @if (column.StartsWith("Value") && columnSums.ContainsKey(column))
{ {
<MudTd><b>@valueSum.ToString("N2")</b></MudTd> <MudTd><b>@columnSums[column].ToString("N2")</b></MudTd>
} }
else else
{ {
@@ -178,6 +181,7 @@
{ {
<MudTd></MudTd> <MudTd></MudTd>
} }
}
</FooterContent> </FooterContent>
</MudTable> </MudTable>
@@ -230,6 +234,8 @@
} }
</MudTabPanel> </MudTabPanel>
@if (showHistoryTab)
{
<MudTabPanel Text="History" Icon="@Icons.Material.Filled.History"> <MudTabPanel Text="History" Icon="@Icons.Material.Filled.History">
@if (isLoadingHistory) @if (isLoadingHistory)
{ {
@@ -311,7 +317,7 @@
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Code">@context.Code</MudTd> <MudTd DataLabel="Code">@context.Code</MudTd>
<MudTd DataLabel="Description">@context.Desc1</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> <MudTd DataLabel="Modified By">@GetModifiedByUsername(context.ModifiedById)</MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
@@ -354,6 +360,7 @@
} }
} }
</MudTabPanel> </MudTabPanel>
}
</MudTabs> </MudTabs>
} }
</MudCardContent> </MudCardContent>

View File

@@ -6,7 +6,7 @@ using System.Reflection;
namespace DiunaBI.UI.Shared.Pages.Layers; namespace DiunaBI.UI.Shared.Pages.Layers;
public partial class Details : ComponentBase public partial class Details : ComponentBase, IDisposable
{ {
[Parameter] [Parameter]
public Guid Id { get; set; } public Guid Id { get; set; }
@@ -20,22 +20,32 @@ public partial class Details : ComponentBase
[Inject] [Inject]
private JobService JobService { get; set; } = null!; private JobService JobService { get; set; } = null!;
[Inject]
private EntityChangeHubService HubService { get; set; } = null!;
[Inject] [Inject]
private NavigationManager NavigationManager { get; set; } = null!; private NavigationManager NavigationManager { get; set; } = null!;
[Inject] [Inject]
private ISnackbar Snackbar { get; set; } = null!; private ISnackbar Snackbar { get; set; } = null!;
[Inject]
private DateTimeHelper DateTimeHelper { get; set; } = null!;
private LayerDto? layer; private LayerDto? layer;
private List<RecordDto> records = new(); private List<RecordDto> records = new();
private List<string> displayedColumns = new(); private List<string> displayedColumns = new();
private double valueSum = 0; private double valueSum = 0;
private Dictionary<string, double> columnSums = new();
private double totalSum = 0;
private bool isLoading = false; private bool isLoading = false;
private Guid? editingRecordId = null; private Guid? editingRecordId = null;
private RecordDto? editingRecord = null; private RecordDto? editingRecord = null;
private bool isAddingNew = false; private bool isAddingNew = false;
private RecordDto newRecord = new(); private RecordDto newRecord = new();
private bool isEditable => layer?.Type == LayerType.Dictionary || layer?.Type == LayerType.Administration; private bool isEditable => layer?.Type == LayerType.Dictionary || layer?.Type == LayerType.Administration;
private bool showHistoryTab => layer?.Type == LayerType.Administration || layer?.Type == LayerType.Dictionary;
private bool showSummary => layer?.Type == LayerType.Import || layer?.Type == LayerType.Processed;
// History tab state // History tab state
private bool isLoadingHistory = false; private bool isLoadingHistory = false;
@@ -48,7 +58,41 @@ public partial class Details : ComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await DateTimeHelper.InitializeAsync();
await LoadLayer(); await LoadLayer();
// Subscribe to SignalR entity changes
HubService.EntityChanged += OnEntityChanged;
}
private async void OnEntityChanged(string module, string id, string operation)
{
// React to Layers or Records changes for this layer
if (module.Equals("Layers", StringComparison.OrdinalIgnoreCase) ||
module.Equals("Records", StringComparison.OrdinalIgnoreCase))
{
// Check if it's this layer or its records that changed
if (Guid.TryParse(id, out var changedId))
{
if (module.Equals("Layers", StringComparison.OrdinalIgnoreCase) && changedId == Id)
{
await InvokeAsync(async () =>
{
await LoadLayer();
StateHasChanged();
});
}
else if (module.Equals("Records", StringComparison.OrdinalIgnoreCase))
{
// For records, we reload to get the latest data
await InvokeAsync(async () =>
{
await LoadLayer();
StateHasChanged();
});
}
}
}
} }
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
@@ -79,7 +123,7 @@ public partial class Details : ComponentBase
{ {
records = layer.Records.OrderBy(r => r.Code).ToList(); records = layer.Records.OrderBy(r => r.Code).ToList();
CalculateDisplayedColumns(); CalculateDisplayedColumns();
CalculateValueSum(); CalculateColumnSums();
BuildUserCache(); BuildUserCache();
} }
} }
@@ -118,11 +162,25 @@ public partial class Details : ComponentBase
} }
} }
private void CalculateValueSum() private void CalculateColumnSums()
{ {
valueSum = records columnSums.Clear();
.Where(r => r.Value1.HasValue) totalSum = 0;
.Sum(r => r.Value1!.Value);
// Calculate sum for each displayed value column
foreach (var columnName in displayedColumns.Where(c => c.StartsWith("Value")))
{
var sum = records
.Select(r => GetRecordValueByName(r, columnName))
.Where(v => v.HasValue)
.Sum(v => v!.Value);
columnSums[columnName] = sum;
totalSum += sum;
}
// Keep valueSum for backward compatibility (Value1 sum)
valueSum = columnSums.ContainsKey("Value1") ? columnSums["Value1"] : 0;
} }
private string GetRecordValue(RecordDto record, string columnName) private string GetRecordValue(RecordDto record, string columnName)
@@ -246,7 +304,7 @@ public partial class Details : ComponentBase
{ {
records.Remove(record); records.Remove(record);
CalculateDisplayedColumns(); CalculateDisplayedColumns();
CalculateValueSum(); CalculateColumnSums();
Snackbar.Add("Record deleted successfully", Severity.Success); Snackbar.Add("Record deleted successfully", Severity.Success);
} }
else else
@@ -297,7 +355,7 @@ public partial class Details : ComponentBase
{ {
records.Add(created); records.Add(created);
CalculateDisplayedColumns(); CalculateDisplayedColumns();
CalculateValueSum(); CalculateColumnSums();
isAddingNew = false; isAddingNew = false;
newRecord = new(); newRecord = new();
Snackbar.Add("Record added successfully", Severity.Success); Snackbar.Add("Record added successfully", Severity.Success);
@@ -473,4 +531,9 @@ public partial class Details : ComponentBase
isRunningJob = false; isRunningJob = false;
} }
} }
public void Dispose()
{
HubService.EntityChanged -= OnEntityChanged;
}
} }

View File

@@ -1,6 +1,7 @@
@page "/layers" @page "/layers"
@using MudBlazor.Internal @using MudBlazor.Internal
@using DiunaBI.Application.DTOModels @using DiunaBI.Application.DTOModels
@implements IDisposable
<PageTitle>Layers</PageTitle> <PageTitle>Layers</PageTitle>

View File

@@ -8,9 +8,10 @@ using Microsoft.JSInterop;
namespace DiunaBI.UI.Shared.Pages.Layers; namespace DiunaBI.UI.Shared.Pages.Layers;
public partial class Index : ComponentBase public partial class Index : ComponentBase, IDisposable
{ {
[Inject] private LayerService LayerService { get; set; } = default!; [Inject] private LayerService LayerService { get; set; } = default!;
[Inject] private EntityChangeHubService HubService { get; set; } = default!;
[Inject] private ISnackbar Snackbar { get; set; } = default!; [Inject] private ISnackbar Snackbar { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!;
[Inject] private LayerFilterStateService FilterStateService { get; set; } = default!; [Inject] private LayerFilterStateService FilterStateService { get; set; } = default!;
@@ -25,6 +26,22 @@ public partial class Index : ComponentBase
{ {
filterRequest = FilterStateService.FilterRequest; filterRequest = FilterStateService.FilterRequest;
await LoadLayers(); await LoadLayers();
// Subscribe to SignalR entity changes
HubService.EntityChanged += OnEntityChanged;
}
private async void OnEntityChanged(string module, string id, string operation)
{
// Only react if it's a Layers change
if (module.Equals("Layers", StringComparison.OrdinalIgnoreCase))
{
await InvokeAsync(async () =>
{
await LoadLayers();
StateHasChanged();
});
}
} }
private async Task LoadLayers() private async Task LoadLayers()
@@ -89,4 +106,9 @@ public partial class Index : ComponentBase
var url = NavigationManager.ToAbsoluteUri($"/layers/{layer.Id}").ToString(); var url = NavigationManager.ToAbsoluteUri($"/layers/{layer.Id}").ToString();
await JSRuntime.InvokeVoidAsync("open", url, "_blank"); await JSRuntime.InvokeVoidAsync("open", url, "_blank");
} }
public void Dispose()
{
HubService.EntityChanged -= OnEntityChanged;
}
} }

View File

@@ -3,4 +3,5 @@ namespace DiunaBI.UI.Shared.Services;
public class AppConfig public class AppConfig
{ {
public string AppName { get; set; } = "DiunaBI"; public string AppName { get; set; } = "DiunaBI";
public string ClientLogo {get; set;} = "pedrollopl.png";
} }

View File

@@ -15,16 +15,18 @@ public class AuthService
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IJSRuntime _jsRuntime; private readonly IJSRuntime _jsRuntime;
private readonly TokenProvider _tokenProvider;
private bool? _isAuthenticated; private bool? _isAuthenticated;
private UserInfo? _userInfo = null; private UserInfo? _userInfo = null;
private string? _apiToken; private string? _apiToken;
public event Action<bool>? AuthenticationStateChanged; public event Action<bool>? AuthenticationStateChanged;
public AuthService(HttpClient httpClient, IJSRuntime jsRuntime) public AuthService(HttpClient httpClient, IJSRuntime jsRuntime, TokenProvider tokenProvider)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_jsRuntime = jsRuntime; _jsRuntime = jsRuntime;
_tokenProvider = tokenProvider;
} }
public bool IsAuthenticated => _isAuthenticated ?? false; public bool IsAuthenticated => _isAuthenticated ?? false;
@@ -44,6 +46,7 @@ public class AuthService
if (result != null) if (result != null)
{ {
_apiToken = result.Token; _apiToken = result.Token;
_tokenProvider.Token = result.Token; // Set token for SignalR
_userInfo = new UserInfo _userInfo = new UserInfo
{ {
Id = result.Id, Id = result.Id,
@@ -104,6 +107,7 @@ public class AuthService
if (_isAuthenticated.Value && !string.IsNullOrEmpty(userInfoJson)) if (_isAuthenticated.Value && !string.IsNullOrEmpty(userInfoJson))
{ {
_apiToken = token; _apiToken = token;
_tokenProvider.Token = token; // Set token for SignalR
_userInfo = JsonSerializer.Deserialize<UserInfo>(userInfoJson); _userInfo = JsonSerializer.Deserialize<UserInfo>(userInfoJson);
// Restore header // Restore header
@@ -111,6 +115,9 @@ public class AuthService
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiToken); new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiToken);
Console.WriteLine($"✅ Session restored: {_userInfo?.Email}"); Console.WriteLine($"✅ Session restored: {_userInfo?.Email}");
// Notify that authentication state changed (for SignalR initialization)
AuthenticationStateChanged?.Invoke(true);
} }
else else
{ {
@@ -139,6 +146,7 @@ public class AuthService
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "user_info"); await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "user_info");
_apiToken = null; _apiToken = null;
_tokenProvider.Token = null; // Clear token for SignalR
_isAuthenticated = false; _isAuthenticated = false;
_userInfo = null; _userInfo = null;

View 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;
}
}

View File

@@ -7,6 +7,7 @@ public class EntityChangeHubService : IAsyncDisposable
{ {
private readonly string _hubUrl; private readonly string _hubUrl;
private readonly ILogger<EntityChangeHubService> _logger; private readonly ILogger<EntityChangeHubService> _logger;
private readonly TokenProvider _tokenProvider;
private HubConnection? _hubConnection; private HubConnection? _hubConnection;
private bool _isInitialized; private bool _isInitialized;
private readonly SemaphoreSlim _initializationLock = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _initializationLock = new SemaphoreSlim(1, 1);
@@ -19,7 +20,8 @@ public class EntityChangeHubService : IAsyncDisposable
public EntityChangeHubService( public EntityChangeHubService(
string apiBaseUrl, string apiBaseUrl,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILogger<EntityChangeHubService> logger) ILogger<EntityChangeHubService> logger,
TokenProvider tokenProvider)
{ {
_instanceId = Interlocked.Increment(ref _instanceCounter); _instanceId = Interlocked.Increment(ref _instanceCounter);
@@ -28,6 +30,7 @@ public class EntityChangeHubService : IAsyncDisposable
_hubUrl = baseUrl + "/hubs/entitychanges"; _hubUrl = baseUrl + "/hubs/entitychanges";
_logger = logger; _logger = logger;
_tokenProvider = tokenProvider;
_logger.LogInformation("🏗️ EntityChangeHubService instance #{InstanceId} created. Hub URL: {HubUrl}", _instanceId, _hubUrl); _logger.LogInformation("🏗️ EntityChangeHubService instance #{InstanceId} created. Hub URL: {HubUrl}", _instanceId, _hubUrl);
Console.WriteLine($"🏗️ EntityChangeHubService instance #{_instanceId} created. Hub URL: {_hubUrl}, _isInitialized = {_isInitialized}"); Console.WriteLine($"🏗️ EntityChangeHubService instance #{_instanceId} created. Hub URL: {_hubUrl}, _isInitialized = {_isInitialized}");
} }
@@ -58,7 +61,21 @@ public class EntityChangeHubService : IAsyncDisposable
Console.WriteLine($"🔌 Initializing SignalR connection to {_hubUrl}"); Console.WriteLine($"🔌 Initializing SignalR connection to {_hubUrl}");
_hubConnection = new HubConnectionBuilder() _hubConnection = new HubConnectionBuilder()
.WithUrl(_hubUrl) .WithUrl(_hubUrl, options =>
{
// Add JWT token to SignalR connection
if (!string.IsNullOrEmpty(_tokenProvider.Token))
{
options.AccessTokenProvider = () => Task.FromResult<string?>(_tokenProvider.Token);
_logger.LogInformation("✅ JWT token added to SignalR connection");
Console.WriteLine($"✅ JWT token added to SignalR connection");
}
else
{
_logger.LogWarning("⚠️ No JWT token available for SignalR connection");
Console.WriteLine($"⚠️ No JWT token available for SignalR connection");
}
})
.WithAutomaticReconnect() .WithAutomaticReconnect()
.Build(); .Build();

View File

@@ -19,13 +19,18 @@ public class JobService
PropertyNameCaseInsensitive = true 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 start = (page - 1) * pageSize;
var query = $"Jobs?start={start}&limit={pageSize}"; var query = $"Jobs?start={start}&limit={pageSize}";
if (status.HasValue) if (statuses != null && statuses.Count > 0)
query += $"&status={(int)status.Value}"; {
foreach (var status in statuses)
{
query += $"&statuses={(int)status}";
}
}
if (jobType.HasValue) if (jobType.HasValue)
query += $"&jobType={(int)jobType.Value}"; query += $"&jobType={(int)jobType.Value}";
@@ -83,6 +88,89 @@ public class JobService
return await response.Content.ReadFromJsonAsync<CreateJobResult>(); 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 public class JobStats

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -31,9 +31,24 @@
<a class="dismiss">🗙</a> <a class="dismiss">🗙</a>
</div> </div>
<div id="components-reconnect-modal" data-nosnippet>
<div class="reconnect-content">
<div class="reconnect-spinner"></div>
<h5>Connection Lost</h5>
<div class="reconnect-message">
Attempting to reconnect to the server...
</div>
<div class="reconnect-timer">
<span id="reconnect-elapsed-time">0s</span>
</div>
<button onclick="location.reload()">Reload Page</button>
</div>
</div>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="_content/DiunaBI.UI.Shared/js/auth.js"></script> <script src="_content/DiunaBI.UI.Shared/js/auth.js"></script>
<script src="js/reconnect.js"></script>
</body> </body>
</html> </html>

View File

@@ -17,9 +17,6 @@ builder.Services.AddSharedServices(apiBaseUrl);
// Configure App settings // Configure App settings
var appConfig = builder.Configuration.GetSection("App").Get<AppConfig>() ?? new AppConfig(); var appConfig = builder.Configuration.GetSection("App").Get<AppConfig>() ?? new AppConfig();
Console.WriteLine($"[DEBUG] AppConfig.AppName from config: {appConfig.AppName}");
Console.WriteLine($"[DEBUG] App:AppName from Configuration: {builder.Configuration["App:AppName"]}");
Console.WriteLine($"[DEBUG] App__AppName env var: {Environment.GetEnvironmentVariable("App__AppName")}");
builder.Services.AddSingleton(appConfig); builder.Services.AddSingleton(appConfig);
builder.Services.AddScoped<IGoogleAuthService, WebGoogleAuthService>(); builder.Services.AddScoped<IGoogleAuthService, WebGoogleAuthService>();

View File

@@ -58,3 +58,93 @@ h1:focus {
.mud-pagination li::marker { .mud-pagination li::marker {
display: none; display: none;
} }
/* Blazor Server Reconnection UI Customization */
#components-reconnect-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 9999;
font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
display: none !important;
align-items: center;
justify-content: center;
}
/* Show modal when Blazor applies these classes */
#components-reconnect-modal.components-reconnect-show,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-rejected {
display: flex !important;
}
#components-reconnect-modal .reconnect-content {
background: white;
border-radius: 8px;
padding: 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
max-width: 400px;
text-align: center;
}
#components-reconnect-modal h5 {
margin: 0 0 16px 0;
color: #424242;
font-size: 20px;
font-weight: 500;
}
#components-reconnect-modal .reconnect-message {
color: #666;
margin-bottom: 24px;
font-size: 14px;
line-height: 1.5;
}
#components-reconnect-modal .reconnect-spinner {
width: 48px;
height: 48px;
border: 4px solid #f3f3f3;
border-top: 4px solid #e7163d;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#components-reconnect-modal .reconnect-timer {
color: #e7163d;
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
}
#components-reconnect-modal button {
background-color: #e7163d;
color: white;
border: none;
border-radius: 4px;
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#components-reconnect-modal button:hover {
background-color: #c01234;
}
#components-reconnect-modal button:active {
background-color: #a01028;
}

View File

@@ -0,0 +1,82 @@
// Blazor Server Reconnection Timer
(function() {
let reconnectTimer = null;
let startTime = null;
function startTimer() {
if (reconnectTimer) return; // Already running
console.log('Blazor reconnection started, timer running...');
startTime = Date.now();
reconnectTimer = setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
const timerElement = document.getElementById('reconnect-elapsed-time');
if (timerElement) {
timerElement.textContent = `${elapsedSeconds}s`;
}
}, 1000);
}
function stopTimer() {
if (reconnectTimer) {
console.log('Blazor reconnection ended, stopping timer');
clearInterval(reconnectTimer);
reconnectTimer = null;
// Reset timer display
const timerElement = document.getElementById('reconnect-elapsed-time');
if (timerElement) {
timerElement.textContent = '0s';
}
}
}
function checkReconnectionState() {
const modal = document.getElementById('components-reconnect-modal');
if (!modal) return;
// Check if modal has the "show" class (Blazor applies this when reconnecting)
if (modal.classList.contains('components-reconnect-show')) {
startTimer();
} else {
stopTimer();
}
}
// MutationObserver to watch for class changes on the modal
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
checkReconnectionState();
}
});
});
// Start observing when DOM is ready
function init() {
const modal = document.getElementById('components-reconnect-modal');
if (modal) {
observer.observe(modal, {
attributes: true,
attributeFilter: ['class']
});
// Check initial state
checkReconnectionState();
console.log('Blazor reconnection timer initialized');
} else {
console.warn('components-reconnect-modal not found, retrying...');
setTimeout(init, 100);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();