From 6c4812fe6dd2d753f20ca618b622d0a6bb8c0da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Bu=C4=9Fra=20K=C3=B6sen?= Date: Wed, 20 May 2026 21:50:36 +0300 Subject: [PATCH 01/26] feat(api,ui): Add cursor-based pagination and workflow updates Introduce cursor-based pagination for job occurrences and enhance workflow triggering with per-step job data overrides. * Add `.env` and `.env.local` to `.gitignore` for local environment management. * Add `GetJobOccurenceListCursorQuery` and handler for cursor-based pagination. * Update `JobsController` with `GetJobOccurrencesCursorAsync` endpoint. * Add `StepJobData` to `WorkflowRuns` via `WorkflowJobDataOverride` migration. * Enhance `WorkflowStepDto` with `WorkerId` and `JobNameInWorker`. * Refactor `GetWorkflowDetailQueryHandler` to include job info for steps. * Add `TriggerWorkflowCommand` and handler for per-step job data overrides. * Implement `MergeJobData` method in `WorkflowEngineService`. * Update `OccurrenceTable` and `ExecutionList` for cursor-based pagination. * Add `TriggerWorkflowModal` for workflow triggering with step overrides. * Update `WorkflowDetail` and `WorkflowList` to integrate the modal. * Enhance `occurrenceService` and `workflowService` for new features. * Upgrade NuGet dependencies for improved functionality and compatibility. * Update unit tests to reflect changes in `JobDataMap` handling. * Add new CSS styles for `TriggerWorkflowModal` and pagination updates. --- .gitignore | 4 + for multi-arch support | 314 ---- .../AppStartup/ServiceCollectionExtensions.cs | 2 - .../Controllers/JobsController.cs | 13 +- ...154451_WorkflowJobDataOverride.Designer.cs | 1484 +++++++++++++++++ .../20260512154451_WorkflowJobDataOverride.cs | 21 + .../MilvaionDbContextModelSnapshot.cs | 5 +- src/Milvaion.Api/Milvaion.Api.csproj | 22 +- src/Milvaion.Api/Milvaion.Api.xml | 20 + .../Dtos/WorkflowDtos/WorkflowDetailDto.cs | 10 + .../GetDashboard/GetDashboardQueryHandler.cs | 6 +- .../GetJobOccurenceListCursorQuery.cs | 17 + .../GetJobOccurenceListCursorQueryHandler.cs | 90 + .../GetWorkflowDetailQueryHandler.cs | 45 +- .../TriggerWorkflow/TriggerWorkflowCommand.cs | 7 + .../TriggerWorkflowCommandHandler.cs | 1 + .../Milvaion.Application.csproj | 19 +- .../Milvaion.Application.xml | 59 + src/Milvaion.Domain/Milvaion.Domain.csproj | 7 +- .../ExternalJobTrackerService.cs | 8 +- .../JobDispatcherService.cs | 1 + .../BackgroundServices/LogCollectorService.cs | 9 +- .../WorkflowEngineService.cs | 48 +- .../ZombieOccurrenceDetectorService.cs | 4 +- .../Milvaion.Infrastructure.csproj | 15 +- .../Milvaion.Infrastructure.xml | 7 + src/MilvaionUI/.env | 1 + src/MilvaionUI/dev-dist/sw.js | 2 +- .../src/components/OccurrenceTable.css | 4 +- .../src/components/OccurrenceTable.jsx | 52 +- .../src/components/TriggerWorkflowModal.css | 260 +++ .../src/components/TriggerWorkflowModal.jsx | 276 +++ .../src/pages/Executions/ExecutionList.css | 6 + .../src/pages/Executions/ExecutionList.jsx | 86 +- .../src/pages/Workflows/WorkflowDetail.css | 2 + .../src/pages/Workflows/WorkflowDetail.jsx | 33 +- .../src/pages/Workflows/WorkflowList.jsx | 31 +- .../src/services/occurrenceService.js | 25 + .../src/services/workflowService.js | 7 +- ...vasoft.Milvaion.Sdk.Worker.Hangfire.csproj | 4 +- .../Listeners/MilvaionJobListener.cs | 4 +- ...ilvasoft.Milvaion.Sdk.Worker.Quartz.csproj | 4 +- .../Milvasoft.Milvaion.Sdk.Worker.csproj | 8 +- .../Domain/WorkflowRun.cs | 9 + .../Milvasoft.Milvaion.Sdk.csproj | 6 +- src/Workers/EmailWorker/EmailWorker.csproj | 8 +- src/Workers/HttpWorker/HttpWorker.csproj | 4 +- .../MilvaionMaintenanceWorker.csproj | 8 +- .../ReporterWorker/ReporterWorker.csproj | 7 +- .../SampleHangfireWorker.csproj | 4 +- .../SampleQuartzWorker.csproj | 4 +- src/Workers/SampleWorker/SampleWorker.csproj | 4 +- src/Workers/SqlWorker/SqlWorker.csproj | 10 +- .../Milvaion.IntegrationTests.csproj | 16 +- .../Milvaion.UnitTests.csproj | 8 +- .../MilvaionJobListenerTests.cs | 12 +- 56 files changed, 2648 insertions(+), 495 deletions(-) delete mode 100644 for multi-arch support create mode 100644 src/Milvaion.Api/Migrations/20260512154451_WorkflowJobDataOverride.Designer.cs create mode 100644 src/Milvaion.Api/Migrations/20260512154451_WorkflowJobDataOverride.cs create mode 100644 src/Milvaion.Application/Features/ScheduledJobs/GetJobOccurenceListCursor/GetJobOccurenceListCursorQuery.cs create mode 100644 src/Milvaion.Application/Features/ScheduledJobs/GetJobOccurenceListCursor/GetJobOccurenceListCursorQueryHandler.cs create mode 100644 src/MilvaionUI/src/components/TriggerWorkflowModal.css create mode 100644 src/MilvaionUI/src/components/TriggerWorkflowModal.jsx diff --git a/.gitignore b/.gitignore index ec8b4a2..5593d06 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,7 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd /CoverageReport + +# Local environment variables +.env +.env.local diff --git a/for multi-arch support b/for multi-arch support deleted file mode 100644 index 1250959..0000000 --- a/for multi-arch support +++ /dev/null @@ -1,314 +0,0 @@ - - SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS - - Commands marked with * may be preceded by a number, _N. - Notes in parentheses indicate the behavior if _N is given. - A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K. - - h H Display this help. - q :q Q :Q ZZ Exit. - --------------------------------------------------------------------------- - - MMOOVVIINNGG - - e ^E j ^N CR * Forward one line (or _N lines). - y ^Y k ^K ^P * Backward one line (or _N lines). - f ^F ^V SPACE * Forward one window (or _N lines). - b ^B ESC-v * Backward one window (or _N lines). - z * Forward one window (and set window to _N). - w * Backward one window (and set window to _N). - ESC-SPACE * Forward one window, but don't stop at end-of-file. - d ^D * Forward one half-window (and set half-window to _N). - u ^U * Backward one half-window (and set half-window to _N). - ESC-) RightArrow * Right one half screen width (or _N positions). - ESC-( LeftArrow * Left one half screen width (or _N positions). - ESC-} ^RightArrow Right to last column displayed. - ESC-{ ^LeftArrow Left to first column. - F Forward forever; like "tail -f". - ESC-F Like F but stop when search pattern is found. - r ^R ^L Repaint screen. - R Repaint screen, discarding buffered input. - --------------------------------------------------- - Default "window" is the screen height. - Default "half-window" is half of the screen height. - --------------------------------------------------------------------------- - - SSEEAARRCCHHIINNGG - - /_p_a_t_t_e_r_n * Search forward for (_N-th) matching line. - ?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line. - n * Repeat previous search (for _N-th occurrence). - N * Repeat previous search in reverse direction. - ESC-n * Repeat previous search, spanning files. - ESC-N * Repeat previous search, reverse dir. & spanning files. - ^O^N ^On * Search forward for (_N-th) OSC8 hyperlink. - ^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink. - ^O^L ^Ol Jump to the currently selected OSC8 hyperlink. - ESC-u Undo (toggle) search highlighting. - ESC-U Clear search highlighting. - &_p_a_t_t_e_r_n * Display only matching lines. - --------------------------------------------------- - A search pattern may begin with one or more of: - ^N or ! Search for NON-matching lines. - ^E or * Search multiple files (pass thru END OF FILE). - ^F or @ Start search at FIRST file (for /) or last file (for ?). - ^K Highlight matches, but don't move (KEEP position). - ^R Don't use REGULAR EXPRESSIONS. - ^S _n Search for match in _n-th parenthesized subpattern. - ^W WRAP search if no match found. - ^L Enter next character literally into pattern. - --------------------------------------------------------------------------- - - JJUUMMPPIINNGG - - g < ESC-< * Go to first line in file (or line _N). - G > ESC-> * Go to last line in file (or line _N). - p % * Go to beginning of file (or _N percent into file). - t * Go to the (_N-th) next tag. - T * Go to the (_N-th) previous tag. - { ( [ * Find close bracket } ) ]. - } ) ] * Find open bracket { ( [. - ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>. - ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>. - --------------------------------------------------- - Each "find close bracket" command goes forward to the close bracket - matching the (_N-th) open bracket in the top line. - Each "find open bracket" command goes backward to the open bracket - matching the (_N-th) close bracket in the bottom line. - - m_<_l_e_t_t_e_r_> Mark the current top line with . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_l_e_t_t_e_r_> Go to a previously marked position. - '' Go to the previous position. - ^X^X Same as '. - ESC-m_<_l_e_t_t_e_r_> Clear a mark. - --------------------------------------------------- - A mark is any upper-case or lower-case letter. - Certain marks are predefined: - ^ means beginning of the file - $ means end of the file - --------------------------------------------------------------------------- - - CCHHAANNGGIINNGG FFIILLEESS - - :e [_f_i_l_e] Examine a new file. - ^X^V Same as :e. - :n * Examine the (_N-th) next file from the command line. - :p * Examine the (_N-th) previous file from the command line. - :x * Examine the first (or _N-th) file from the command line. - ^O^O Open the currently selected OSC8 hyperlink. - :d Delete the current file from the command line list. - = ^G :f Print current file name. - --------------------------------------------------------------------------- - - MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS - - -_<_f_l_a_g_> Toggle a command line option [see OPTIONS below]. - --_<_n_a_m_e_> Toggle a command line option, by name. - __<_f_l_a_g_> Display the setting of a command line option. - ___<_n_a_m_e_> Display the setting of an option, by name. - +_c_m_d Execute the less cmd each time a new file is examined. - - !_c_o_m_m_a_n_d Execute the shell command with $SHELL. - #_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt. - |XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command. - s _f_i_l_e Save input to a file. - v Edit the current file with $VISUAL or $EDITOR. - V Print version number of "less". - --------------------------------------------------------------------------- - - OOPPTTIIOONNSS - - Most options may be changed either on the command line, - or from within less by using the - or -- command. - Options may be given in one of two forms: either a single - character preceded by a -, or a name preceded by --. - - -? ........ --help - Display help (from command line). - -a ........ --search-skip-screen - Search skips current screen. - -A ........ --SEARCH-SKIP-SCREEN - Search starts just after target line. - -b [_N] .... --buffers=[_N] - Number of buffers. - -B ........ --auto-buffers - Don't automatically allocate buffers for pipes. - -c ........ --clear-screen - Repaint by clearing rather than scrolling. - -d ........ --dumb - Dumb terminal. - -D xx_c_o_l_o_r . --color=xx_c_o_l_o_r - Set screen colors. - -e -E .... --quit-at-eof --QUIT-AT-EOF - Quit at end of file. - -f ........ --force - Force open non-regular files. - -F ........ --quit-if-one-screen - Quit if entire file fits on first screen. - -g ........ --hilite-search - Highlight only last match for searches. - -G ........ --HILITE-SEARCH - Don't highlight any matches for searches. - -h [_N] .... --max-back-scroll=[_N] - Backward scroll limit. - -i ........ --ignore-case - Ignore case in searches that do not contain uppercase. - -I ........ --IGNORE-CASE - Ignore case in all searches. - -j [_N] .... --jump-target=[_N] - Screen position of target lines. - -J ........ --status-column - Display a status column at left edge of screen. - -k _f_i_l_e ... --lesskey-file=_f_i_l_e - Use a compiled lesskey file. - -K ........ --quit-on-intr - Exit less in response to ctrl-C. - -L ........ --no-lessopen - Ignore the LESSOPEN environment variable. - -m -M .... --long-prompt --LONG-PROMPT - Set prompt style. - -n ......... --line-numbers - Suppress line numbers in prompts and messages. - -N ......... --LINE-NUMBERS - Display line number at start of each line. - -o [_f_i_l_e] .. --log-file=[_f_i_l_e] - Copy to log file (standard input only). - -O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e] - Copy to log file (unconditionally overwrite). - -p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n] - Start at pattern (from command line). - -P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t] - Define new prompt. - -q -Q .... --quiet --QUIET --silent --SILENT - Quiet the terminal bell. - -r -R .... --raw-control-chars --RAW-CONTROL-CHARS - Output "raw" control characters. - -s ........ --squeeze-blank-lines - Squeeze multiple blank lines. - -S ........ --chop-long-lines - Chop (truncate) long lines rather than wrapping. - -t _t_a_g .... --tag=[_t_a_g] - Find a tag. - -T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e] - Use an alternate tags file. - -u -U .... --underline-special --UNDERLINE-SPECIAL - Change handling of backspaces, tabs and carriage returns. - -V ........ --version - Display the version number of "less". - -w ........ --hilite-unread - Highlight first new line after forward-screen. - -W ........ --HILITE-UNREAD - Highlight first new line after any forward movement. - -x [_N[,...]] --tabs=[_N[,...]] - Set tab stops. - -X ........ --no-init - Don't use termcap init/deinit strings. - -y [_N] .... --max-forw-scroll=[_N] - Forward scroll limit. - -z [_N] .... --window=[_N] - Set size of window. - -" [_c[_c]] . --quotes=[_c[_c]] - Set shell quote characters. - -~ ........ --tilde - Don't display tildes after end of file. - -# [_N] .... --shift=[_N] - Set horizontal scroll amount (0 = one half screen width). - - --exit-follow-on-close - Exit F command on a pipe when writer closes pipe. - --file-size - Automatically determine the size of the input file. - --follow-name - The F command changes files if the input file is renamed. - --header=[_L[,_C[,_N]]] - Use _L lines (starting at line _N) and _C columns as headers. - --incsearch - Search file as each pattern character is typed in. - --intr=[_C] - Use _C instead of ^X to interrupt a read. - --lesskey-context=_t_e_x_t - Use lesskey source file contents. - --lesskey-src=_f_i_l_e - Use a lesskey source file. - --line-num-width=[_N] - Set the width of the -N line number field to _N characters. - --match-shift=[_N] - Show at least _N characters to the left of a search match. - --modelines=[_N] - Read _N lines from the input file and look for vim modelines. - --mouse - Enable mouse input. - --no-keypad - Don't send termcap keypad init/deinit strings. - --no-histdups - Remove duplicates from command history. - --no-number-headers - Don't give line numbers to header lines. - --no-search-header-lines - Searches do not include header lines. - --no-search-header-columns - Searches do not include header columns. - --no-search-headers - Searches do not include header lines or columns. - --no-vbell - Disable the terminal's visual bell. - --redraw-on-quit - Redraw final screen when quitting. - --rscroll=[_C] - Set the character used to mark truncated lines. - --save-marks - Retain marks across invocations of less. - --search-options=[EFKNRW-] - Set default options for every search. - --show-preproc-errors - Display a message if preprocessor exits with an error status. - --proc-backspace - Process backspaces for bold/underline. - --PROC-BACKSPACE - Treat backspaces as control characters. - --proc-return - Delete carriage returns before newline. - --PROC-RETURN - Treat carriage returns as control characters. - --proc-tab - Expand tabs to spaces. - --PROC-TAB - Treat tabs as control characters. - --status-col-width=[_N] - Set the width of the -J status column to _N characters. - --status-line - Highlight or color the entire line containing a mark. - --use-backslash - Subsequent options use backslash as escape char. - --use-color - Enables colored text. - --wheel-lines=[_N] - Each click of the mouse wheel moves _N lines. - --wordwrap - Wrap lines at spaces. - - - --------------------------------------------------------------------------- - - LLIINNEE EEDDIITTIINNGG - - These keys can be used to edit text being entered - on the "command line" at the bottom of the screen. - - RightArrow ..................... ESC-l ... Move cursor right one character. - LeftArrow ...................... ESC-h ... Move cursor left one character. - ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word. - ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word. - HOME ........................... ESC-0 ... Move cursor to start of line. - END ............................ ESC-$ ... Move cursor to end of line. - BACKSPACE ................................ Delete char to left of cursor. - DELETE ......................... ESC-x ... Delete char under cursor. - ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor. - ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor. - ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line. - UpArrow ........................ ESC-k ... Retrieve previous command line. - DownArrow ...................... ESC-j ... Retrieve next command line. - TAB ...................................... Complete filename & cycle. - SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle. - ctrl-L ................................... Complete filename, list all. diff --git a/src/Milvaion.Api/AppStartup/ServiceCollectionExtensions.cs b/src/Milvaion.Api/AppStartup/ServiceCollectionExtensions.cs index db61df2..42a95e7 100644 --- a/src/Milvaion.Api/AppStartup/ServiceCollectionExtensions.cs +++ b/src/Milvaion.Api/AppStartup/ServiceCollectionExtensions.cs @@ -326,8 +326,6 @@ public static void AddObservibilityAndLogging(this WebApplicationBuilder builder private static LoggerConfiguration ApplyLoggingFromConfig(this LoggerConfiguration loggerConfig, WebApplicationBuilder builder) { - loggerConfig.WriteTo.Console(); - var seqEnabled = builder.Configuration.GetSection("MilvaionConfig:Logging:Seq:Enabled").Get(); if (seqEnabled) diff --git a/src/Milvaion.Api/Controllers/JobsController.cs b/src/Milvaion.Api/Controllers/JobsController.cs index 7beae85..85f5aa4 100644 --- a/src/Milvaion.Api/Controllers/JobsController.cs +++ b/src/Milvaion.Api/Controllers/JobsController.cs @@ -13,6 +13,7 @@ using Milvaion.Application.Features.ScheduledJobs.DeleteScheduledJob; using Milvaion.Application.Features.ScheduledJobs.GetJobOccurenceDetail; using Milvaion.Application.Features.ScheduledJobs.GetJobOccurenceList; +using Milvaion.Application.Features.ScheduledJobs.GetJobOccurenceListCursor; using Milvaion.Application.Features.ScheduledJobs.GetScheduledJobDetail; using Milvaion.Application.Features.ScheduledJobs.GetScheduledJobList; using Milvaion.Application.Features.ScheduledJobs.GetTagList; @@ -105,9 +106,19 @@ public class JobsController(IMediator mediator) : ControllerBase /// /// [Auth(PermissionCatalog.ScheduledJobManagement.List)] - [HttpPatch("occurrences")] + [HttpPatch("occurrences/list")] public Task> GetJobOccurrencesAsync(GetJobOccurrenceListQuery request, CancellationToken cancellation) => _mediator.Send(request, cancellation); + /// + /// Gets job occurrences. + /// + /// + /// + /// + [Auth(PermissionCatalog.ScheduledJobManagement.List)] + [HttpPatch("occurrences")] + public async Task> GetJobOccurrencesCursorAsync(GetJobOccurenceListCursorQuery request, CancellationToken cancellation) => (await _mediator.Send(request, cancellation)).Data; + /// /// Gets job occurrence detail. /// diff --git a/src/Milvaion.Api/Migrations/20260512154451_WorkflowJobDataOverride.Designer.cs b/src/Milvaion.Api/Migrations/20260512154451_WorkflowJobDataOverride.Designer.cs new file mode 100644 index 0000000..2c0fb50 --- /dev/null +++ b/src/Milvaion.Api/Migrations/20260512154451_WorkflowJobDataOverride.Designer.cs @@ -0,0 +1,1484 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Milvaion.Domain.JsonModels; +using Milvaion.Infrastructure.Persistence.Context; +using Milvasoft.Milvaion.Sdk.Domain.JsonModels; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Milvaion.Api.Migrations +{ + [DbContext(typeof(MilvaionDbContext))] + [Migration("20260512154451_WorkflowJobDataOverride")] + partial class WorkflowJobDataOverride + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Milvaion.Domain.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Activity") + .HasColumnType("smallint"); + + b.Property("ActivityDate") + .HasMaxLength(255) + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ActivityDate") + .IsDescending(); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.Content", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("KeyAlias") + .HasColumnType("text"); + + b.Property("LanguageId") + .HasColumnType("integer"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("NamespaceId") + .HasColumnType("integer"); + + b.Property("NamespaceSlug") + .HasColumnType("text"); + + b.Property("ResourceGroupId") + .HasColumnType("integer"); + + b.Property("ResourceGroupSlug") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NamespaceId"); + + b.HasIndex("ResourceGroupId"); + + b.HasIndex("LanguageId", "KeyAlias") + .IsUnique(); + + b.ToTable("Contents"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentId") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ContentId"); + + b.ToTable("Medias"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.Namespace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Slug") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Namespaces"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.ResourceGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NamespaceId") + .HasColumnType("integer"); + + b.Property("Slug") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug"); + + b.HasIndex("NamespaceId", "Slug") + .IsUnique(); + + b.ToTable("ResourceGroups"); + }); + + modelBuilder.Entity("Milvaion.Domain.InternalNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionLink") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("RecipientUserId") + .HasColumnType("integer"); + + b.Property("RecipientUserName") + .HasColumnType("text"); + + b.Property("RelatedEntityId") + .HasColumnType("text"); + + b.Property("RelatedEntityType") + .HasColumnType("smallint"); + + b.Property("SeenDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Text") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("RecipientUserName"); + + b.ToTable("InternalNotifications"); + }); + + modelBuilder.Entity("Milvaion.Domain.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Supported") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Languages"); + }); + + modelBuilder.Entity("Milvaion.Domain.MigrationHistory", b => + { + b.Property("MigrationId") + .HasColumnType("text"); + + b.Property("MigrationCompleted") + .HasColumnType("boolean"); + + b.HasKey("MigrationId"); + + b.ToTable("_MigrationHistory"); + }); + + modelBuilder.Entity("Milvaion.Domain.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PermissionGroup") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PermissionGroupDescription") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("PermissionGroup", "Name") + .IsUnique(); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Milvaion.Domain.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("DeleterUserName") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("Milvaion.Domain.RolePermissionRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionId") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PermissionId"); + + b.HasIndex("RoleId"); + + b.ToTable("RolePermissionRelations"); + }); + + modelBuilder.Entity("Milvaion.Domain.UI.MenuGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property>("Translations") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("MenuGroups"); + }); + + modelBuilder.Entity("Milvaion.Domain.UI.MenuItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PageName") + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.PrimitiveCollection("PermissionOrGroupNames") + .HasColumnType("jsonb"); + + b.Property>("Translations") + .HasColumnType("jsonb"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("ParentId"); + + b.ToTable("MenuItems"); + }); + + modelBuilder.Entity("Milvaion.Domain.UI.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection("CreatePermissions") + .HasColumnType("jsonb"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.PrimitiveCollection("DeletePermissions") + .HasColumnType("jsonb"); + + b.PrimitiveCollection("DetailPermissions") + .HasColumnType("jsonb"); + + b.PrimitiveCollection("EditPermissions") + .HasColumnType("jsonb"); + + b.Property("HasCreate") + .HasColumnType("boolean"); + + b.Property("HasDelete") + .HasColumnType("boolean"); + + b.Property("HasDetail") + .HasColumnType("boolean"); + + b.Property("HasEdit") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Pages"); + }); + + modelBuilder.Entity("Milvaion.Domain.UI.PageAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionName") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("PageId") + .HasColumnType("integer"); + + b.PrimitiveCollection("Permissions") + .HasColumnType("jsonb"); + + b.Property>("Translations") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("PageActions"); + }); + + modelBuilder.Entity("Milvaion.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.PrimitiveCollection("AllowedNotifications") + .HasColumnType("jsonb"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("DeleterUserName") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("Surname") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UserType") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("UserName", "IsDeleted", "DeletionDate") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Milvaion.Domain.UserRoleRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("UserRoleRelations"); + }); + + modelBuilder.Entity("Milvaion.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("DeviceId") + .HasColumnType("text"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserName", "DeviceId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Milvaion.Domain.UserSessionHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("DeviceId") + .HasColumnType("text"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("UserSessionHistories"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.FailedOccurrence", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureType") + .HasColumnType("integer"); + + b.Property("JobData") + .HasColumnType("jsonb"); + + b.Property("JobDisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("JobNameInWorker") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("OriginalExecuteAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolutionAction") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResolutionNote") + .HasColumnType("text"); + + b.Property("Resolved") + .HasColumnType("boolean"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("WorkerId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("OccurrenceId"); + + b.HasIndex("FailureType", "Resolved"); + + b.HasIndex("Resolved", "FailedAt") + .IsDescending(false, true); + + b.ToTable("FailedOccurrences"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.JobOccurrence", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("DispatchRetryCount") + .HasColumnType("integer"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("integer"); + + b.Property("ExternalJobId") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("JobName") + .HasColumnType("text"); + + b.Property("JobVersion") + .HasColumnType("integer"); + + b.Property("LastHeartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("NextDispatchRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Result") + .HasColumnType("text"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StatusChangeLogs") + .HasColumnType("jsonb"); + + b.Property("StepRetryCount") + .HasColumnType("integer"); + + b.Property("StepScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StepStatus") + .HasColumnType("integer"); + + b.Property("WorkerId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowRunId") + .HasColumnType("uuid"); + + b.Property("WorkflowStepId") + .HasColumnType("uuid"); + + b.Property("ZombieTimeoutMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("WorkflowRunId"); + + b.ToTable("JobOccurrences"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.JobOccurrenceLog", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property>("Data") + .HasColumnType("jsonb"); + + b.Property("ExceptionType") + .HasColumnType("text"); + + b.Property("Level") + .HasColumnType("text"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("OccurrenceId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OccurrenceId"); + + b.ToTable("JobOccurrenceLogs"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.MetricReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MetricType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PeriodEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("PeriodStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Tags") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.ToTable("MetricReports"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.ScheduledJob", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AutoDisableSettings") + .HasColumnType("jsonb"); + + b.Property("ConcurrentExecutionPolicy") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("CronExpression") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("ExecuteAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("integer"); + + b.Property("ExternalJobId") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.Property("JobData") + .HasColumnType("jsonb"); + + b.Property("JobNameInWorker") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.PrimitiveCollection("JobVersions") + .HasColumnType("jsonb"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("RoutingPattern") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("integer"); + + b.Property("WorkerId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ZombieTimeoutMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ScheduledJobs"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.Workflow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("CronExpression") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FailureStrategy") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastModificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifierUserName") + .HasColumnType("text"); + + b.Property("LastScheduledRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxStepRetries") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Tags") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("Version") + .HasColumnType("integer"); + + b.Property>("Versions") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("Workflows"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.WorkflowRun", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorUserName") + .HasColumnType("text"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StepJobData") + .HasColumnType("jsonb"); + + b.Property("TriggerReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.Property("WorkflowVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowId"); + + b.ToTable("WorkflowRuns"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.Content", b => + { + b.HasOne("Milvaion.Domain.ContentManagement.Namespace", "Namespace") + .WithMany("Contents") + .HasForeignKey("NamespaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Milvaion.Domain.ContentManagement.ResourceGroup", "ResourceGroup") + .WithMany("Contents") + .HasForeignKey("ResourceGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Namespace"); + + b.Navigation("ResourceGroup"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.Media", b => + { + b.HasOne("Milvaion.Domain.ContentManagement.Content", "Content") + .WithMany("Medias") + .HasForeignKey("ContentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Content"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.ResourceGroup", b => + { + b.HasOne("Milvaion.Domain.ContentManagement.Namespace", "Namespace") + .WithMany("ResourceGroups") + .HasForeignKey("NamespaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Namespace"); + }); + + modelBuilder.Entity("Milvaion.Domain.RolePermissionRelation", b => + { + b.HasOne("Milvaion.Domain.Permission", "Permission") + .WithMany("RolePermissionRelations") + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Milvaion.Domain.Role", "Role") + .WithMany("RolePermissionRelations") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Milvaion.Domain.UI.MenuItem", b => + { + b.HasOne("Milvaion.Domain.UI.MenuGroup", "Group") + .WithMany("MenuItems") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Milvaion.Domain.UI.MenuItem", "Parent") + .WithMany("Childrens") + .HasForeignKey("ParentId"); + + b.Navigation("Group"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Milvaion.Domain.UI.PageAction", b => + { + b.HasOne("Milvaion.Domain.UI.Page", "Page") + .WithMany("AdditionalActions") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Page"); + }); + + modelBuilder.Entity("Milvaion.Domain.UserRoleRelation", b => + { + b.HasOne("Milvaion.Domain.Role", "Role") + .WithMany("UserRoleRelations") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Milvaion.Domain.User", "User") + .WithMany("RoleRelations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Milvaion.Domain.UserSession", b => + { + b.HasOne("Milvaion.Domain.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.FailedOccurrence", b => + { + b.HasOne("Milvasoft.Milvaion.Sdk.Domain.ScheduledJob", "Job") + .WithMany("FailedOccurrences") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Milvasoft.Milvaion.Sdk.Domain.JobOccurrence", "Occurrence") + .WithMany() + .HasForeignKey("OccurrenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("Occurrence"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.JobOccurrence", b => + { + b.HasOne("Milvasoft.Milvaion.Sdk.Domain.ScheduledJob", "Job") + .WithMany("Occurrences") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Milvasoft.Milvaion.Sdk.Domain.WorkflowRun", "WorkflowRun") + .WithMany("StepOccurrences") + .HasForeignKey("WorkflowRunId"); + + b.Navigation("Job"); + + b.Navigation("WorkflowRun"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.JobOccurrenceLog", b => + { + b.HasOne("Milvasoft.Milvaion.Sdk.Domain.JobOccurrence", "Occurrence") + .WithMany("Logs") + .HasForeignKey("OccurrenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Occurrence"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.WorkflowRun", b => + { + b.HasOne("Milvasoft.Milvaion.Sdk.Domain.Workflow", "Workflow") + .WithMany("Runs") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.Content", b => + { + b.Navigation("Medias"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.Namespace", b => + { + b.Navigation("Contents"); + + b.Navigation("ResourceGroups"); + }); + + modelBuilder.Entity("Milvaion.Domain.ContentManagement.ResourceGroup", b => + { + b.Navigation("Contents"); + }); + + modelBuilder.Entity("Milvaion.Domain.Permission", b => + { + b.Navigation("RolePermissionRelations"); + }); + + modelBuilder.Entity("Milvaion.Domain.Role", b => + { + b.Navigation("RolePermissionRelations"); + + b.Navigation("UserRoleRelations"); + }); + + modelBuilder.Entity("Milvaion.Domain.UI.MenuGroup", b => + { + b.Navigation("MenuItems"); + }); + + modelBuilder.Entity("Milvaion.Domain.UI.MenuItem", b => + { + b.Navigation("Childrens"); + }); + + modelBuilder.Entity("Milvaion.Domain.UI.Page", b => + { + b.Navigation("AdditionalActions"); + }); + + modelBuilder.Entity("Milvaion.Domain.User", b => + { + b.Navigation("RoleRelations"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.JobOccurrence", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.ScheduledJob", b => + { + b.Navigation("FailedOccurrences"); + + b.Navigation("Occurrences"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.Workflow", b => + { + b.Navigation("Runs"); + }); + + modelBuilder.Entity("Milvasoft.Milvaion.Sdk.Domain.WorkflowRun", b => + { + b.Navigation("StepOccurrences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Milvaion.Api/Migrations/20260512154451_WorkflowJobDataOverride.cs b/src/Milvaion.Api/Migrations/20260512154451_WorkflowJobDataOverride.cs new file mode 100644 index 0000000..09c9d09 --- /dev/null +++ b/src/Milvaion.Api/Migrations/20260512154451_WorkflowJobDataOverride.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Milvaion.Api.Migrations; + +/// +public partial class WorkflowJobDataOverride : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.AddColumn>( + name: "StepJobData", + table: "WorkflowRuns", + type: "jsonb", + nullable: true); + + /// + protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.DropColumn( + name: "StepJobData", + table: "WorkflowRuns"); +} diff --git a/src/Milvaion.Api/Migrations/MilvaionDbContextModelSnapshot.cs b/src/Milvaion.Api/Migrations/MilvaionDbContextModelSnapshot.cs index 56a4834..ea6edc9 100644 --- a/src/Milvaion.Api/Migrations/MilvaionDbContextModelSnapshot.cs +++ b/src/Milvaion.Api/Migrations/MilvaionDbContextModelSnapshot.cs @@ -20,7 +20,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("ProductVersion", "10.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -1207,6 +1207,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .HasColumnType("integer"); + b.Property>("StepJobData") + .HasColumnType("jsonb"); + b.Property("TriggerReason") .HasMaxLength(500) .HasColumnType("character varying(500)"); diff --git a/src/Milvaion.Api/Milvaion.Api.csproj b/src/Milvaion.Api/Milvaion.Api.csproj index 7f0dabd..5f0fb34 100644 --- a/src/Milvaion.Api/Milvaion.Api.csproj +++ b/src/Milvaion.Api/Milvaion.Api.csproj @@ -27,25 +27,25 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + - + - - - + + + - + diff --git a/src/Milvaion.Api/Milvaion.Api.xml b/src/Milvaion.Api/Milvaion.Api.xml index 908cf10..bdd3290 100644 --- a/src/Milvaion.Api/Milvaion.Api.xml +++ b/src/Milvaion.Api/Milvaion.Api.xml @@ -732,6 +732,14 @@ + + + Gets job occurrences. + + + + + Gets job occurrence detail. @@ -1299,6 +1307,18 @@ + + + + + + + + + + + + Applies migrations when the application starts. diff --git a/src/Milvaion.Application/Dtos/WorkflowDtos/WorkflowDetailDto.cs b/src/Milvaion.Application/Dtos/WorkflowDtos/WorkflowDetailDto.cs index 47eed7e..7a84396 100644 --- a/src/Milvaion.Application/Dtos/WorkflowDtos/WorkflowDetailDto.cs +++ b/src/Milvaion.Application/Dtos/WorkflowDtos/WorkflowDetailDto.cs @@ -106,6 +106,16 @@ public class WorkflowStepDto /// public string JobDisplayName { get; set; } + /// + /// Worker ID that owns the job (used for schema lookup in UI). + /// + public string WorkerId { get; set; } + + /// + /// Job name in worker (used for schema lookup in UI). + /// + public string JobNameInWorker { get; set; } + /// /// User-friendly label for this step. /// diff --git a/src/Milvaion.Application/Features/Dashboard/GetDashboard/GetDashboardQueryHandler.cs b/src/Milvaion.Application/Features/Dashboard/GetDashboard/GetDashboardQueryHandler.cs index ca2bfc0..6d58269 100644 --- a/src/Milvaion.Application/Features/Dashboard/GetDashboard/GetDashboardQueryHandler.cs +++ b/src/Milvaion.Application/Features/Dashboard/GetDashboard/GetDashboardQueryHandler.cs @@ -43,8 +43,10 @@ public async Task> Handle(GetDashboardQuery request, Canc double? avgDuration = durationCount > 0 ? (double)durationSum / durationCount : null; double successRate = 0; - if (totalExecutions > 0) - successRate = completedJobs * 100.0 / totalExecutions; + var finishedJobs = completedJobs + failedJobs + cancelledJobs + timedOutJobs; + + if (finishedJobs > 0) + successRate = completedJobs * 100.0 / finishedJobs; // Get worker statistics from Redis var activeWorkers = workers.Where(w => w.Status == WorkerStatus.Active).ToList(); diff --git a/src/Milvaion.Application/Features/ScheduledJobs/GetJobOccurenceListCursor/GetJobOccurenceListCursorQuery.cs b/src/Milvaion.Application/Features/ScheduledJobs/GetJobOccurenceListCursor/GetJobOccurenceListCursorQuery.cs new file mode 100644 index 0000000..0484598 --- /dev/null +++ b/src/Milvaion.Application/Features/ScheduledJobs/GetJobOccurenceListCursor/GetJobOccurenceListCursorQuery.cs @@ -0,0 +1,17 @@ +using Milvaion.Application.Dtos.ScheduledJobDtos; +using Milvasoft.Components.CQRS.Query; +using Milvasoft.Components.Rest.MilvaResponse; +using Milvasoft.Components.Rest.Request; + +namespace Milvaion.Application.Features.ScheduledJobs.GetJobOccurenceListCursor; + +/// +/// Data transfer object for scheduledjob list. +/// +public record GetJobOccurenceListCursorQuery : CursorListRequest, IQuery> +{ + /// + /// Search term to filter job occurrences. + /// + public string SearchTerm { get; set; } +} \ No newline at end of file diff --git a/src/Milvaion.Application/Features/ScheduledJobs/GetJobOccurenceListCursor/GetJobOccurenceListCursorQueryHandler.cs b/src/Milvaion.Application/Features/ScheduledJobs/GetJobOccurenceListCursor/GetJobOccurenceListCursorQueryHandler.cs new file mode 100644 index 0000000..add6e22 --- /dev/null +++ b/src/Milvaion.Application/Features/ScheduledJobs/GetJobOccurenceListCursor/GetJobOccurenceListCursorQueryHandler.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using Milvaion.Application.Dtos.ScheduledJobDtos; +using Milvaion.Application.Interfaces.Redis; +using Milvasoft.Components.CQRS.Query; +using Milvasoft.Components.Rest.MilvaResponse; +using Milvasoft.Core.Abstractions; +using Milvasoft.DataAccess.EfCore.Utils; +using System.Linq.Expressions; +using System.Text.Json; + +namespace Milvaion.Application.Features.ScheduledJobs.GetJobOccurenceListCursor; + +/// +/// Handles the job occurrence list operation. +/// +/// +/// +public class GetJobOccurenceListCursorQueryHandler(IMilvaionDbContextAccessor milvaionDbContextAccessor, IRedisStatsService redisStatsService) : IInterceptable, IQueryHandler> +{ + private readonly IMilvaionDbContextAccessor _milvaionDbContextAccessor = milvaionDbContextAccessor; + private readonly IRedisStatsService _redisStatsService = redisStatsService; + + /// + public async Task>> Handle(GetJobOccurenceListCursorQuery request, CancellationToken cancellationToken) + { + Expression> predicate = null; + int? manualCount = null; + + var jobIdFilter = request.Filtering?.Criterias?.FirstOrDefault(i => i.FilterBy == nameof(JobOccurrence.JobId)); + var statusFilter = request.Filtering?.Criterias?.FirstOrDefault(i => i.FilterBy == nameof(JobOccurrence.Status)); + + // Only use Redis stats when there are no filters that would make it inaccurate (e.g., JobId filter) + var canUseRedisStats = jobIdFilter is null; + + if (canUseRedisStats) + { + if (statusFilter is not null) + { + var redisStats = await _redisStatsService.GetStatisticsAsync(cancellationToken); + manualCount = (int?)redisStats.GetValueOrDefault(Enum.GetName(GetValueWithCorrectType(statusFilter.Value)), 0); + } + else if (string.IsNullOrWhiteSpace(request.SearchTerm)) + { + var redisStats = await _redisStatsService.GetStatisticsAsync(cancellationToken); + manualCount = (int)redisStats.GetValueOrDefault("Total", 0); + } + } + + if (!string.IsNullOrWhiteSpace(request.SearchTerm)) + { + if (Guid.TryParse(request.SearchTerm, out var guid)) + predicate = c => c.Id == guid; + else + predicate = c => EF.Functions.ILike(c.JobName, $"%{request.SearchTerm.Trim()}%"); + } + + var context = _milvaionDbContextAccessor.GetDbContext(); + + var response = await context.Set().ToCursorListResponseAsync(request, JobOccurrenceListDto.Projection, cancellationToken: cancellationToken); + + response.TotalDataCount = manualCount ?? response.TotalDataCount; + + return Response>.Success(response); + } + + /// + /// Gets the value with the correct type from the object. + /// + /// + /// + /// + public static TActualType GetValueWithCorrectType(object value) + { + try + { + if (value is TActualType typedValue) + return typedValue; + + if (value is JsonElement jsonElement) + return jsonElement.Deserialize(); + + // Fallback: Try Convert.ChangeType for simple types like int, bool, etc. + return (TActualType)Convert.ChangeType(value, typeof(TActualType)); + } + catch + { + return default; + } + } +} \ No newline at end of file diff --git a/src/Milvaion.Application/Features/Workflows/GetWorkflowDetail/GetWorkflowDetailQueryHandler.cs b/src/Milvaion.Application/Features/Workflows/GetWorkflowDetail/GetWorkflowDetailQueryHandler.cs index ce4159c..2f5e367 100644 --- a/src/Milvaion.Application/Features/Workflows/GetWorkflowDetail/GetWorkflowDetailQueryHandler.cs +++ b/src/Milvaion.Application/Features/Workflows/GetWorkflowDetail/GetWorkflowDetailQueryHandler.cs @@ -22,19 +22,14 @@ public async Task> Handle(GetWorkflowDetailQuery req if (workflow == null) return Response.Success(null, "Workflow not found"); - // Get job names for steps + // Get job info for steps (display name, workerId, jobNameInWorker for schema lookup) var jobIds = workflow.Definition?.Steps?.Where(s => s.JobId.HasValue).Select(s => s.JobId.Value).Distinct().ToList() ?? []; - var jobNames = new Dictionary(); + var jobInfoById = new Dictionary(); var jobs = await _jobRepository.GetAllAsync(j => jobIds.Contains(j.Id), cancellationToken: cancellationToken); - foreach (var jobId in jobIds) - { - var job = jobs.FirstOrDefault(j => j.Id == jobId); - - if (job != null) - jobNames[jobId] = job.DisplayName; - } + foreach (var job in jobs) + jobInfoById[job.Id] = (job.DisplayName, job.WorkerId, job.JobNameInWorker); var dto = new WorkflowDetailDto { @@ -51,20 +46,26 @@ public async Task> Handle(GetWorkflowDetailQuery req LastScheduledRunAt = workflow.LastScheduledRunAt, WorkflowVersions = workflow.Versions?.OrderByDescending(i => i.Version).ToList(), AuditInfo = new AuditDto(workflow), - Steps = workflow.Definition?.Steps?.Select(s => new WorkflowStepDto + Steps = workflow.Definition?.Steps?.Select(s => { - Id = s.Id, - NodeType = s.NodeType, - JobId = s.JobId, - JobDisplayName = s.JobId.HasValue ? jobNames.GetValueOrDefault(s.JobId.Value, "Unknown") : null, - StepName = s.StepName, - Order = s.Order, - NodeConfigJson = s.NodeConfigJson, - DataMappings = s.DataMappings, - DelaySeconds = s.DelaySeconds, - JobDataOverride = s.JobDataOverride, - PositionX = s.PositionX, - PositionY = s.PositionY, + jobInfoById.TryGetValue(s.JobId ?? Guid.Empty, out var ji); + return new WorkflowStepDto + { + Id = s.Id, + NodeType = s.NodeType, + JobId = s.JobId, + JobDisplayName = ji.DisplayName, + WorkerId = ji.WorkerId, + JobNameInWorker = ji.JobNameInWorker, + StepName = s.StepName, + Order = s.Order, + NodeConfigJson = s.NodeConfigJson, + DataMappings = s.DataMappings, + DelaySeconds = s.DelaySeconds, + JobDataOverride = s.JobDataOverride, + PositionX = s.PositionX, + PositionY = s.PositionY, + }; }).OrderBy(s => s.Order).ToList(), Edges = workflow.Definition?.Edges?.Select(e => new WorkflowEdgeDto { diff --git a/src/Milvaion.Application/Features/Workflows/TriggerWorkflow/TriggerWorkflowCommand.cs b/src/Milvaion.Application/Features/Workflows/TriggerWorkflow/TriggerWorkflowCommand.cs index bff912b..bfbdd88 100644 --- a/src/Milvaion.Application/Features/Workflows/TriggerWorkflow/TriggerWorkflowCommand.cs +++ b/src/Milvaion.Application/Features/Workflows/TriggerWorkflow/TriggerWorkflowCommand.cs @@ -16,4 +16,11 @@ public record TriggerWorkflowCommand : ICommand /// Trigger reason. /// public string Reason { get; set; } + + /// + /// Per-step job data overrides for this run. + /// Key is the step ID (), value is JSON job data. + /// Takes highest priority — overrides base data, design-time step override, and global job data. + /// + public Dictionary StepJobData { get; set; } } diff --git a/src/Milvaion.Application/Features/Workflows/TriggerWorkflow/TriggerWorkflowCommandHandler.cs b/src/Milvaion.Application/Features/Workflows/TriggerWorkflow/TriggerWorkflowCommandHandler.cs index edce10d..017bc08 100644 --- a/src/Milvaion.Application/Features/Workflows/TriggerWorkflow/TriggerWorkflowCommandHandler.cs +++ b/src/Milvaion.Application/Features/Workflows/TriggerWorkflow/TriggerWorkflowCommandHandler.cs @@ -44,6 +44,7 @@ public async Task> Handle(TriggerWorkflowCommand request, Cancell CorrelationId = correlationId, Status = WorkflowStatus.Pending, TriggerReason = request.Reason ?? "Manual trigger", + StepJobData = request.StepJobData, CreatedAt = now, }; diff --git a/src/Milvaion.Application/Milvaion.Application.csproj b/src/Milvaion.Application/Milvaion.Application.csproj index a112889..f7a73b2 100644 --- a/src/Milvaion.Application/Milvaion.Application.csproj +++ b/src/Milvaion.Application/Milvaion.Application.csproj @@ -24,16 +24,17 @@ - + - - - - - - - - + + + + + + + + + diff --git a/src/Milvaion.Application/Milvaion.Application.xml b/src/Milvaion.Application/Milvaion.Application.xml index 6511669..19b337b 100644 --- a/src/Milvaion.Application/Milvaion.Application.xml +++ b/src/Milvaion.Application/Milvaion.Application.xml @@ -4093,6 +4093,16 @@ Job display name. + + + Worker ID that owns the job (used for schema lookup in UI). + + + + + Job name in worker (used for schema lookup in UI). + + User-friendly label for this step. @@ -6623,6 +6633,41 @@ + + + Data transfer object for scheduledjob list. + + + + + Search term to filter job occurrences. + + + + + Handles the job occurrence list operation. + + + + + + + Handles the job occurrence list operation. + + + + + + + + + + Gets the value with the correct type from the object. + + + + + Data transfer object for scheduledjob list. @@ -7655,6 +7700,13 @@ Trigger reason. + + + Per-step job data overrides for this run. + Key is the step ID (), value is JSON job data. + Takes highest priority — overrides base data, design-time step override, and global job data. + + Handles workflow triggering. Creates a WorkflowRun with step runs for all steps. @@ -10057,6 +10109,13 @@ Minimum iterations before checking for leaks (default: 100). + + + Optional sub-path under which the application is hosted (e.g. "/milvaion"). + Leave empty or null when hosted at the root. + Must match the VITE_BASE_PATH used when the frontend was built. + + RabbitMQ configuration options for job dispatcher. diff --git a/src/Milvaion.Domain/Milvaion.Domain.csproj b/src/Milvaion.Domain/Milvaion.Domain.csproj index adf8698..38e9afa 100644 --- a/src/Milvaion.Domain/Milvaion.Domain.csproj +++ b/src/Milvaion.Domain/Milvaion.Domain.csproj @@ -23,9 +23,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + diff --git a/src/Milvaion.Infrastructure/BackgroundServices/ExternalJobTrackerService.cs b/src/Milvaion.Infrastructure/BackgroundServices/ExternalJobTrackerService.cs index 0398228..5a1ea53 100644 --- a/src/Milvaion.Infrastructure/BackgroundServices/ExternalJobTrackerService.cs +++ b/src/Milvaion.Infrastructure/BackgroundServices/ExternalJobTrackerService.cs @@ -279,7 +279,7 @@ private async Task ProcessRegistrationBatchAsync(CancellationToken cancellationT var externalIds = jobsByExternalId.Keys.ToList(); // Find existing jobs by ExternalJobId field - var existingJobs = await dbContext.ScheduledJobs.Where(j => j.IsExternal && externalIds.Contains(j.ExternalJobId)).ToListAsync(cancellationToken); + var existingJobs = await dbContext.ScheduledJobs.AsNoTracking().Where(j => j.IsExternal && externalIds.Contains(j.ExternalJobId)).ToListAsync(cancellationToken); var existingJobDict = existingJobs.ToDictionary(j => j.ExternalJobId, j => j); @@ -355,7 +355,8 @@ private async Task ProcessRegistrationBatchAsync(CancellationToken cancellationT createdCount++; // Get the created job ID from DB and add to Redis mapping - var createdJob = await dbContext.ScheduledJobs.Where(j => j.IsExternal && j.ExternalJobId == externalId) + var createdJob = await dbContext.ScheduledJobs.AsNoTracking() + .Where(j => j.IsExternal && j.ExternalJobId == externalId) .Select(j => new { j.Id, j.ExternalJobId }) .FirstOrDefaultAsync(cancellationToken); @@ -439,7 +440,8 @@ private async Task ProcessOccurrenceBatchAsync(CancellationToken cancellationTok if (missingExternalIds.Count > 0) { - var dbMappings = await dbContext.ScheduledJobs.Where(j => j.IsExternal && missingExternalIds.Contains(j.ExternalJobId)) + var dbMappings = await dbContext.ScheduledJobs.AsNoTracking() + .Where(j => j.IsExternal && missingExternalIds.Contains(j.ExternalJobId)) .Select(j => new { j.Id, j.ExternalJobId }) .ToListAsync(cancellationToken); diff --git a/src/Milvaion.Infrastructure/BackgroundServices/JobDispatcherService.cs b/src/Milvaion.Infrastructure/BackgroundServices/JobDispatcherService.cs index 1b2b3cf..9a9de9a 100644 --- a/src/Milvaion.Infrastructure/BackgroundServices/JobDispatcherService.cs +++ b/src/Milvaion.Infrastructure/BackgroundServices/JobDispatcherService.cs @@ -1029,6 +1029,7 @@ private async Task PerformStartupRecoveryAsync(CancellationToken cancellationTok do { zombieOccurrences = await dbContext.JobOccurrences + .AsNoTracking() .Where(o => zombieStatuses.Contains(o.Status) && o.CreatedAt < zombieThreshold) .OrderBy(o => o.CreatedAt) // Consistent ordering for batching .Take(zombieBatchSize) diff --git a/src/Milvaion.Infrastructure/BackgroundServices/LogCollectorService.cs b/src/Milvaion.Infrastructure/BackgroundServices/LogCollectorService.cs index 71d8401..9fc632f 100644 --- a/src/Milvaion.Infrastructure/BackgroundServices/LogCollectorService.cs +++ b/src/Milvaion.Infrastructure/BackgroundServices/LogCollectorService.cs @@ -442,10 +442,11 @@ private async Task RetryPendingLogsAsync(CancellationToken cancellationToken) // Check which occurrences exist now var occurrenceIds = logsToRetry.Select(l => l.OccurrenceId).Distinct().ToList(); - var existingOccurrences = await dbContext.JobOccurrences - .Where(o => occurrenceIds.Contains(o.Id)) - .Select(o => o.Id) - .ToListAsync(cancellationToken); + + var existingOccurrences = await dbContext.JobOccurrences.AsNoTracking() + .Where(o => occurrenceIds.Contains(o.Id)) + .Select(o => o.Id) + .ToListAsync(cancellationToken); var existingSet = existingOccurrences.ToHashSet(); diff --git a/src/Milvaion.Infrastructure/BackgroundServices/WorkflowEngineService.cs b/src/Milvaion.Infrastructure/BackgroundServices/WorkflowEngineService.cs index 15c47bf..fc4d6cc 100644 --- a/src/Milvaion.Infrastructure/BackgroundServices/WorkflowEngineService.cs +++ b/src/Milvaion.Infrastructure/BackgroundServices/WorkflowEngineService.cs @@ -140,7 +140,8 @@ private async Task CheckCronWorkflowsAsync(CancellationToken cancellationToken) var now = DateTime.UtcNow; - var cronWorkflows = await dbContext.Workflows.Where(w => w.IsActive && w.CronExpression != null) + var cronWorkflows = await dbContext.Workflows.AsNoTracking() + .Where(w => w.IsActive && w.CronExpression != null) .Select(Workflow.Projections.CheckCron) .ToListAsync(cancellationToken); @@ -193,7 +194,7 @@ private async Task ProcessWorkflowRunsAsync(CancellationToken cancellationT await using var scope = _serviceProvider.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var activeRuns = await dbContext.WorkflowRuns + var activeRuns = await dbContext.WorkflowRuns.AsNoTracking() .Include(r => r.StepOccurrences) .Include(r => r.Workflow) .Where(r => r.Status == WorkflowStatus.Pending || r.Status == WorkflowStatus.Running) @@ -233,7 +234,7 @@ private async Task ProcessWorkflowRunsAsync(CancellationToken cancellationT .Distinct() .ToList(); - var jobsById = jobIds.Count > 0 ? await dbContext.ScheduledJobs.Where(j => jobIds.Contains(j.Id)).ToDictionaryAsync(j => j.Id, cancellationToken) : []; + var jobsById = jobIds.Count > 0 ? await dbContext.ScheduledJobs.AsNoTracking().Where(j => jobIds.Contains(j.Id)).ToDictionaryAsync(j => j.Id, cancellationToken) : []; // Populate JobName for occurrences that were never dispatched (skipped, cancelled, delayed) foreach (var occ in occurrencesToUpdate.Where(o => string.IsNullOrWhiteSpace(o.JobName) && o.WorkflowStepId.HasValue)) @@ -258,7 +259,15 @@ private async Task ProcessWorkflowRunsAsync(CancellationToken cancellationT continue; } - var jobData = !string.IsNullOrWhiteSpace(stepDef.JobDataOverride) ? stepDef.JobDataOverride : job.JobData; + // Merge priority (lowest → highest): + // 1. job.JobData (design-time base) + // 2. stepDef.JobDataOverride (design-time per-step override) + // 3. run.StepJobData[stepId] (run-time per-step override, highest priority) + var jobData = MergeJobData(job.JobData, stepDef.JobDataOverride); + + if (run.StepJobData != null && run.StepJobData.TryGetValue(stepDef.Id, out var runtimeStepData)) + jobData = MergeJobData(jobData, runtimeStepData); + var occurrencesDict = run.StepOccurrences.ToDictionary(o => o.WorkflowStepId!.Value); if (!string.IsNullOrWhiteSpace(stepDef.DataMappings)) @@ -940,6 +949,37 @@ private async Task DispatchStepsAsync(MilvaionDbContext dbContext, } } + /// + /// Merges on top of . + /// Properties in overwrite matching keys in the base. + /// If is null/empty, is returned as-is. + /// + private static string MergeJobData(string baseJobData, string globalJobData) + { + if (string.IsNullOrWhiteSpace(globalJobData)) + return baseJobData; + + try + { + var target = !string.IsNullOrWhiteSpace(baseJobData) ? JsonNode.Parse(baseJobData)?.AsObject() : []; + target ??= []; + + var overlay = JsonNode.Parse(globalJobData)?.AsObject(); + + if (overlay != null) + { + foreach (var prop in overlay) + target[prop.Key] = prop.Value?.DeepClone(); + } + + return target.ToJsonString(); + } + catch + { + return baseJobData; + } + } + /// /// Applies data mappings from parent step outputs to the current step's job data. /// diff --git a/src/Milvaion.Infrastructure/BackgroundServices/ZombieOccurrenceDetectorService.cs b/src/Milvaion.Infrastructure/BackgroundServices/ZombieOccurrenceDetectorService.cs index b6ad6a5..78e7c93 100644 --- a/src/Milvaion.Infrastructure/BackgroundServices/ZombieOccurrenceDetectorService.cs +++ b/src/Milvaion.Infrastructure/BackgroundServices/ZombieOccurrenceDetectorService.cs @@ -167,7 +167,7 @@ await dbContext.BulkUpdateAsync(allProblematicOccurrences, (bc) => /// private async Task<(List occurrences, List logs)> DetectZombieQueuedAsync(MilvaionDbContext dbContext, CancellationToken cancellationToken) { - var queuedOccurrences = await dbContext.JobOccurrences + var queuedOccurrences = await dbContext.JobOccurrences.AsNoTracking() .Where(o => o.Status == JobOccurrenceStatus.Queued && (o.WorkflowRunId == null || o.StepStatus != WorkflowStepStatus.Pending)) .Select(JobOccurrence.Projections.DetectZombie) @@ -248,7 +248,7 @@ await dbContext.BulkUpdateAsync(allProblematicOccurrences, (bc) => { var heartbeatThreshold = DateTime.UtcNow.AddMinutes(-_options.ZombieTimeoutMinutes); - var lostOccurrences = await dbContext.JobOccurrences + var lostOccurrences = await dbContext.JobOccurrences.AsNoTracking() .Where(o => o.Status == JobOccurrenceStatus.Running && (o.LastHeartbeat == null || o.LastHeartbeat < heartbeatThreshold)) .Select(JobOccurrence.Projections.RecoverLostJob) .ToListAsync(cancellationToken); diff --git a/src/Milvaion.Infrastructure/Milvaion.Infrastructure.csproj b/src/Milvaion.Infrastructure/Milvaion.Infrastructure.csproj index c474de9..7a49ab1 100644 --- a/src/Milvaion.Infrastructure/Milvaion.Infrastructure.csproj +++ b/src/Milvaion.Infrastructure/Milvaion.Infrastructure.csproj @@ -29,21 +29,22 @@ - - - - + + + + + - + - + - + diff --git a/src/Milvaion.Infrastructure/Milvaion.Infrastructure.xml b/src/Milvaion.Infrastructure/Milvaion.Infrastructure.xml index 95f07a4..58cf4ff 100644 --- a/src/Milvaion.Infrastructure/Milvaion.Infrastructure.xml +++ b/src/Milvaion.Infrastructure/Milvaion.Infrastructure.xml @@ -663,6 +663,13 @@ then publishes to RabbitMQ. Failures are bulk-saved in a single pass. + + + Merges on top of . + Properties in overwrite matching keys in the base. + If is null/empty, is returned as-is. + + Applies data mappings from parent step outputs to the current step's job data. diff --git a/src/MilvaionUI/.env b/src/MilvaionUI/.env index d844a28..2277360 100644 --- a/src/MilvaionUI/.env +++ b/src/MilvaionUI/.env @@ -3,3 +3,4 @@ VITE_API_BASE_URL=http://localhost:5000/api/v1 VITE_API_URL=http://localhost:5000 VITE_APP_NAME=Milvaion Scheduler VITE_APP_VERSION=1.0.0 +VITE_BASE_PATH=/milvaion diff --git a/src/MilvaionUI/dev-dist/sw.js b/src/MilvaionUI/dev-dist/sw.js index e41af2a..3266eda 100644 --- a/src/MilvaionUI/dev-dist/sw.js +++ b/src/MilvaionUI/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.fte55548ql" + "revision": "0.227aah0sl8" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/MilvaionUI/src/components/OccurrenceTable.css b/src/MilvaionUI/src/components/OccurrenceTable.css index 87bafca..d3b78b0 100644 --- a/src/MilvaionUI/src/components/OccurrenceTable.css +++ b/src/MilvaionUI/src/components/OccurrenceTable.css @@ -313,8 +313,8 @@ display: inline-flex; align-items: center; justify-content: center; + gap: 0.25rem; min-width: 36px; - max-width: 20px; } .pagination .btn:hover:not(:disabled) { @@ -335,7 +335,7 @@ } .pagination .btn-sm { - padding: 0.375rem 0.625rem; + padding: 0.375rem 0.75rem; font-size: 0.8125rem; } diff --git a/src/MilvaionUI/src/components/OccurrenceTable.jsx b/src/MilvaionUI/src/components/OccurrenceTable.jsx index a383a8a..8206346 100644 --- a/src/MilvaionUI/src/components/OccurrenceTable.jsx +++ b/src/MilvaionUI/src/components/OccurrenceTable.jsx @@ -15,7 +15,12 @@ function OccurrenceTable({ onPageChange, onPageSizeChange, onBulkDelete, - showJobName = false + showJobName = false, + useCursorPagination = false, + hasNextPage = false, + hasPreviousPage = false, + onNextPage, + onPreviousPage, }) { const [currentTime, setCurrentTime] = useState(Date.now()) const [selectedOccurrences, setSelectedOccurrences] = useState([]) @@ -95,6 +100,51 @@ function OccurrenceTable({ const totalPages = Math.ceil(totalCount / pageSize) const renderPagination = () => { + if (useCursorPagination) { + return ( +
+
+ + + Page {currentPage} + {totalCount > 0 ? ` (${totalCount} total)` : ''} + + +
+
+ + +
+
+ ) + } + if (totalPages <= 1 && totalCount <= pageSize) return null const maxVisiblePages = 5 diff --git a/src/MilvaionUI/src/components/TriggerWorkflowModal.css b/src/MilvaionUI/src/components/TriggerWorkflowModal.css new file mode 100644 index 0000000..26008b5 --- /dev/null +++ b/src/MilvaionUI/src/components/TriggerWorkflowModal.css @@ -0,0 +1,260 @@ +/* ── TriggerWorkflowModal ─────────────────────────────────── */ + +.twm-fetch-loading { + display: flex; + align-items: center; + gap: 8px; + justify-content: center; + padding: 2rem; + color: var(--text-muted); +} + +.twm-modal { + width: 660px; + max-width: 95vw; + max-height: 88vh; + display: flex; + flex-direction: column; +} + +.twm-body { + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.25rem 1.5rem; +} + +.twm-field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.twm-label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.twm-hint { + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--text-muted); + margin-left: 0.25rem; +} + +/* Steps */ +.twm-steps { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.twm-steps-label { + margin-bottom: 0.25rem; +} + +.twm-step { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.875rem 1rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.twm-step-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.twm-step-order { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-muted); + background: var(--bg-tertiary); + border-radius: 4px; + padding: 1px 6px; +} + +.twm-step-name { + font-size: 0.9rem; +} + +.twm-step-job { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.76rem; + color: var(--text-muted); + background: var(--bg-tertiary); + border-radius: 4px; + padding: 2px 7px; + margin-left: auto; +} + +/* Schema */ +.twm-schema { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 0.625rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.twm-schema-error { + font-size: 0.78rem; + color: var(--color-failed); +} + +.twm-schema-header { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.76rem; + font-weight: 600; + color: var(--text-secondary); +} + +.twm-schema-gen-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-left: auto; + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.twm-schema-gen-btn:hover { + background: var(--accent-color); + color: #fff; + border-color: var(--accent-color); +} + +.twm-schema-props { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.twm-schema-prop { + display: flex; + align-items: baseline; + gap: 0.4rem; + flex-wrap: wrap; + font-size: 0.78rem; +} + +.twm-prop-name { + font-weight: 600; + color: var(--text-primary); + font-family: 'Fira Code', monospace; +} + +.twm-prop-type { + border-radius: 3px; + padding: 0 5px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} + +.twm-type-string { background: #1e6a4a22; color: #2ecc71; } +.twm-type-number, +.twm-type-integer { background: #1a4a9022; color: #5b9cf6; } +.twm-type-boolean { background: #6a1a6a22; color: #c678dd; } +.twm-type-array, +.twm-type-object { background: #6a4a1a22; color: #e5c07b; } + +.twm-prop-required { + font-size: 0.68rem; + font-weight: 700; + color: var(--color-failed); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.twm-prop-enum { + font-size: 0.72rem; + color: var(--text-muted); + font-style: italic; +} + +.twm-prop-desc { + font-size: 0.72rem; + color: var(--text-muted); + flex-basis: 100%; + padding-left: 0.25rem; +} + +.twm-schema-toggle { + background: none; + border: none; + color: var(--accent-color); + font-size: 0.74rem; + cursor: pointer; + padding: 0; + text-align: left; + width: fit-content; +} + +.twm-schema-raw { + font-size: 0.74rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 0.5rem 0.75rem; + overflow-x: auto; + white-space: pre; + color: var(--text-secondary); +} + +/* Textarea */ +.twm-textarea { + width: 100%; + font-family: 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.82rem; + line-height: 1.5; + resize: vertical; + min-height: 70px; + padding: 0.5rem 0.75rem; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + transition: border-color 0.15s; + box-sizing: border-box; +} + +.twm-textarea:focus { + outline: none; + border-color: var(--accent-color); +} + +.twm-textarea--error { + border-color: var(--color-failed); +} + +.twm-error { + font-size: 0.75rem; + color: var(--color-failed); +} diff --git a/src/MilvaionUI/src/components/TriggerWorkflowModal.jsx b/src/MilvaionUI/src/components/TriggerWorkflowModal.jsx new file mode 100644 index 0000000..4d68508 --- /dev/null +++ b/src/MilvaionUI/src/components/TriggerWorkflowModal.jsx @@ -0,0 +1,276 @@ +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import Icon from './Icon' +import workerService from '../services/workerService' +import workflowService from '../services/workflowService' +import './TriggerWorkflowModal.css' + +// ── helpers ────────────────────────────────────────────────────────────────── + +function generateExampleFromSchema(schema) { + if (typeof schema === 'string') { + try { schema = JSON.parse(schema) } catch { return {} } + } + if (!schema?.properties) return {} + const example = {} + for (const [key, prop] of Object.entries(schema.properties)) { + if (prop.default !== undefined) { example[key] = prop.default; continue } + if (prop.enum?.length) { example[key] = prop.enum[0]; continue } + switch (prop.type) { + case 'string': + example[key] = prop.format === 'date-time' ? new Date().toISOString() + : prop.format === 'date' ? new Date().toISOString().split('T')[0] + : prop.format === 'email' ? 'example@email.com' + : prop.format === 'uri' ? 'https://example.com' + : `<${key}>` + break + case 'integer': case 'number': example[key] = 0; break + case 'boolean': example[key] = false; break + case 'array': example[key] = []; break + case 'object': example[key] = {}; break + default: example[key] = null + } + } + return example +} + +function SchemaViewer({ schema, onGenerate }) { + const [expanded, setExpanded] = useState(false) + let parsed = schema + if (typeof schema === 'string') { + try { parsed = JSON.parse(schema) } catch { return
Invalid schema
} + } + if (!parsed?.properties) return null + const required = parsed.required || [] + return ( +
+
+ + Expected Schema + +
+
+ {Object.entries(parsed.properties).map(([key, prop]) => ( +
+ {key} + {prop.type} + {required.includes(key) && required} + {prop.enum && enum: {prop.enum.join(' | ')}} + {prop.description && {prop.description}} +
+ ))} +
+ + {expanded &&
{JSON.stringify(parsed, null, 2)}
} +
+ ) +} + +SchemaViewer.propTypes = { + schema: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + onGenerate: PropTypes.func.isRequired, +} + +// ── main component ──────────────────────────────────────────────────────────── + +/** + * Modal for triggering a workflow run with optional per-step job data. + * + * Props: + * workflowId – workflow ID to trigger + * onClose – called when modal should close (optionally with error message) + * onSuccess – called with (runId) after successful trigger + */ +export default function TriggerWorkflowModal({ workflowId, workflow: workflowProp, onClose, onSuccess }) { + const [reason, setReason] = useState('') + const [stepData, setStepData] = useState({}) + const [stepErrors, setStepErrors] = useState({}) + const [loading, setLoading] = useState(false) + const [fetchLoading, setFetchLoading] = useState(!workflowProp) + const [workers, setWorkers] = useState([]) + const [workflow, setWorkflow] = useState(workflowProp ?? null) + + const taskSteps = (workflow?.steps || []) + .filter(s => s.nodeType === 0 || s.nodeType == null) + .sort((a, b) => a.order - b.order) + + useEffect(() => { + const id = workflowProp?.id ?? workflowId + if (!id) return + + const fetchWorkers = workerService.getAll().then(r => setWorkers(r?.data || [])).catch(() => {}) + + if (workflowProp) { + fetchWorkers + return + } + + setFetchLoading(true) + Promise.all([ + workflowService.getById(id), + workerService.getAll(), + ]).then(([wfRes, workerRes]) => { + setWorkflow(wfRes?.data ?? null) + setWorkers(workerRes?.data || []) + }).catch(() => {}).finally(() => setFetchLoading(false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const getSchema = (step) => { + if (!step.workerId || !step.jobNameInWorker) return null + const worker = workers.find(w => w.workerId === step.workerId) + return worker?.jobDataDefinitions?.[step.jobNameInWorker] ?? null + } + + const handleDataChange = (stepId, value) => { + setStepData(prev => ({ ...prev, [stepId]: value })) + if (!value.trim()) { + setStepErrors(prev => { const n = { ...prev }; delete n[stepId]; return n }) + return + } + try { + JSON.parse(value) + setStepErrors(prev => { const n = { ...prev }; delete n[stepId]; return n }) + } catch { + setStepErrors(prev => ({ ...prev, [stepId]: 'Invalid JSON' })) + } + } + + const handleGenerateExample = (step) => { + const schema = getSchema(step) + if (!schema) return + const example = generateExampleFromSchema(schema) + handleDataChange(step.id, JSON.stringify(example, null, 2)) + } + + const hasErrors = Object.keys(stepErrors).length > 0 + + const handleSubmit = async () => { + if (hasErrors) return + const stepJobData = {} + Object.entries(stepData).forEach(([id, val]) => { + if (val.trim()) stepJobData[id] = val.trim() + }) + setLoading(true) + try { + const result = await workflowService.trigger(workflow.id, reason || 'Manual trigger', stepJobData) + if (result?.isSuccess) { + onSuccess(result.data) + } else { + onClose(result?.message || 'Failed to trigger workflow') + } + } catch { + onClose('Failed to trigger workflow') + } finally { + setLoading(false) + } + } + + return ( +
!loading && onClose()}> +
e.stopPropagation()}> + + {/* Header */} +
+

Run Workflow

+ +
+ + {/* Body */} +
+ {fetchLoading ? ( +
Loading...
+ ) : (<> +
+ + setReason(e.target.value)} + disabled={loading} + /> +
+ + {/* Per-step data */} + {taskSteps.length > 0 && ( +
+ + + {taskSteps.map(step => { + const schema = getSchema(step) + return ( +
+
+ #{step.order} + {step.stepName} + {step.jobDisplayName && ( + + + {step.jobDisplayName} + + )} +
+ + {schema && ( + handleGenerateExample(step)} + /> + )} + +