├── .gitignore ├── DocAssets ├── dead-job-view.png ├── jobs-worklist.png └── success-job-view.png ├── HelloPgPubSub ├── Consts.cs ├── Database.cs ├── HelloPgPubSub.csproj ├── JobNotificationListener.cs ├── MigrationHostedService.cs ├── Migrations │ ├── 20240205015545_initial.Designer.cs │ ├── 20240205015545_initial.cs │ └── ApplicationDbContextModelSnapshot.cs ├── Program.cs ├── Properties │ └── launchSettings.json └── appsettings.json ├── README.md ├── TaskTower ├── Configurations │ ├── CorsServiceExtension.cs │ ├── TaskTowerOptions.cs │ ├── TaskTowerServiceRegistration.cs │ └── TaskTowerUiMiddlewareExtensions.cs ├── Controllers │ └── v1 │ │ └── JobsController.cs ├── Database │ ├── MigrationConfig.cs │ ├── MigrationHostedService.cs │ └── Migrations │ │ └── Initial.cs ├── Domain │ ├── ContextParameter.cs │ ├── EnqueuedJobs │ │ └── EnqueuedJob.cs │ ├── InterceptionStages │ │ └── InterceptionStage.cs │ ├── JobStatuses │ │ ├── JobStatus.cs │ │ ├── Mappings │ │ │ └── JobStatusTypeHandler.cs │ │ └── RunHistoryStatus.cs │ ├── QueuePrioritizations │ │ └── QueuePrioritization.cs │ ├── RunHistories │ │ ├── Models │ │ │ ├── JobRunHistoryView.cs │ │ │ └── RunHistoryForCreation.cs │ │ ├── RunHistory.cs │ │ └── Services │ │ │ └── JobRunHistoryRepository.cs │ ├── TaskTowerJob │ │ ├── Dtos │ │ │ ├── JobParametersDto.cs │ │ │ ├── TaskTowerJobView.cs │ │ │ └── TaskTowerJobWithTagsView.cs │ │ ├── Features │ │ │ └── GetJobView.cs │ │ ├── Models │ │ │ └── TaskTowerJobForCreation.cs │ │ ├── Services │ │ │ └── TaskTowerJobRepository.cs │ │ └── TaskTowerJob.cs │ ├── TaskTowerTags │ │ └── TaskTowerTag.cs │ └── ValueObject.cs ├── Exceptions │ ├── InvalidTaskTowerTagException.cs │ ├── MissingTaskTowerOptionsException.cs │ ├── TaskTowerException.cs │ └── TaskTowerJobNotFoundException.cs ├── Interception │ ├── InterceptionContextErrorDetails.cs │ ├── JobInterceptor.cs │ ├── JobInterceptorContext.cs │ ├── JobServiceProvider.cs │ ├── TaskTowerRunnerContext.cs │ └── TaskTowerRunnerContextInterceptor.cs ├── Middleware │ └── JobContext.cs ├── Processing │ ├── BackgroundJobClient.cs │ ├── ScheduleBuilder.cs │ └── TaskTowerProcessor.cs ├── React │ └── task-tower-ui │ │ ├── .eslintrc.cjs │ │ ├── .npmrc │ │ ├── index.html │ │ ├── package.json │ │ ├── pnpm-lock.yaml │ │ ├── postcss.config.js │ │ ├── public │ │ └── vite.svg │ │ ├── src │ │ ├── assets │ │ │ └── logo.svg │ │ ├── components │ │ │ ├── badge.tsx │ │ │ ├── data-table │ │ │ │ ├── data-table-column-header.tsx │ │ │ │ ├── pagination.tsx │ │ │ │ ├── trash-button.tsx │ │ │ │ └── types │ │ │ │ │ └── index.ts │ │ │ ├── json-syntax-highlighter.tsx │ │ │ ├── loading-spinner.tsx │ │ │ ├── notifications │ │ │ │ ├── index.ts │ │ │ │ └── notifications.tsx │ │ │ └── ui │ │ │ │ ├── badge-avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── copy-button.tsx │ │ │ │ ├── input.tsx │ │ │ │ └── table.tsx │ │ ├── domain │ │ │ └── jobs │ │ │ │ ├── apis │ │ │ │ ├── delete-jobs.tsx │ │ │ │ ├── get-job-view.tsx │ │ │ │ ├── get-jobs-worklist.tsx │ │ │ │ ├── get-queue-names.tsx │ │ │ │ ├── job.keys.ts │ │ │ │ └── requeue-job.tsx │ │ │ │ ├── components │ │ │ │ ├── job-status.tsx │ │ │ │ ├── job-view.tsx │ │ │ │ └── worklist │ │ │ │ │ ├── job-queue-filter-control.tsx │ │ │ │ │ ├── job-status-filter-control.tsx │ │ │ │ │ ├── jobs-worklist-columns.tsx │ │ │ │ │ ├── jobs-worklist.store.tsx │ │ │ │ │ ├── jobs-worklist.tsx │ │ │ │ │ └── worklist-toolbar.tsx │ │ │ │ └── types.ts │ │ ├── global.d.ts │ │ ├── hooks │ │ │ └── useDebouncedValue.tsx │ │ ├── index.css │ │ ├── layouts │ │ │ └── auth-layout.tsx │ │ ├── lib │ │ │ ├── dev-tools.tsx │ │ │ └── site-config.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── index.tsx │ │ │ └── jobs │ │ │ │ ├── index.tsx │ │ │ │ └── view-job-page.tsx │ │ ├── router.tsx │ │ ├── types │ │ │ └── apis.ts │ │ ├── utils │ │ │ ├── dates │ │ │ │ ├── date.extensions.d.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ └── strings.ts │ │ └── vite-env.d.ts │ │ ├── tailwind.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts ├── Resources │ ├── BasePaginationParameters.cs │ └── PagedList.cs ├── TaskTower.csproj ├── TaskTowerConstants.cs ├── Utils │ ├── BackoffCalculator.cs │ ├── NotificationHelper.cs │ └── StringUtility.cs └── WebApp │ ├── assets │ ├── index-BcRXtPIq.js │ ├── index-CSHZoe13.css │ ├── index-Ds5yF1gS.js │ ├── index-aNIu8cGu.js │ ├── logo-B4WEyPIj.svg │ └── vite.svg │ ├── index.html │ └── vite.svg ├── TaskTowerSandbox ├── LICENSE.md ├── License-lgpl.md ├── Program.cs ├── Properties │ └── launchSettings.json ├── Sandboxing │ ├── DeathPipeline.cs │ ├── DoAThing.cs │ ├── DummyLogger.cs │ ├── FakeSlackService.cs │ ├── FakeTeamsService.cs │ ├── PokeApiService.cs │ └── UserPipeline.cs ├── TaskTowerSandbox.csproj ├── appsettings.Development.json └── appsettings.json ├── docker-compose.yaml └── hello-tasktower.sln /.gitignore: -------------------------------------------------------------------------------- 1 | /.build/ 2 | /global.json 3 | QueryBaseline.cs 4 | 5 | ## Ignore Visual Studio temporary files, build results, and 6 | ## files generated by popular Visual Studio add-ons. 7 | ## 8 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 9 | 10 | # User-specific files 11 | *.suo 12 | *.user 13 | *.userosscache 14 | *.sln.docstates 15 | *.user.sln* 16 | /test.ps1 17 | *.stackdump 18 | 19 | # User-specific files (MonoDevelop/Xamarin Studio) 20 | *.userprefs 21 | 22 | # Build results 23 | [Dd]ebug/ 24 | [Dd]ebugPublic/ 25 | [Rr]elease/ 26 | [Rr]eleases/ 27 | x64/ 28 | x86/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | 34 | # Visual Studio 2015 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # BenchmarkDotNet Results 40 | [Bb]enchmarkDotNet.Artifacts/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUNIT 47 | *.VisualState.xml 48 | TestResult.xml 49 | 50 | # Build Results of an ATL Project 51 | [Dd]ebugPS/ 52 | [Rr]eleasePS/ 53 | dlldata.c 54 | 55 | # .NET Core 56 | project.lock.json 57 | project.fragment.lock.json 58 | artifacts/ 59 | #**/Properties/launchSettings.json 60 | 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.pch 68 | *.pdb 69 | *.pgc 70 | *.pgd 71 | *.rsp 72 | *.sbr 73 | *.tlb 74 | *.tli 75 | *.tlh 76 | *.tmp 77 | *.tmp_proj 78 | *.log 79 | *.vspscc 80 | *.vssscc 81 | .builds 82 | *.pidb 83 | *.svclog 84 | *.scc 85 | 86 | # Chutzpah Test files 87 | _Chutzpah* 88 | 89 | # Visual C++ cache files 90 | ipch/ 91 | *.aps 92 | *.ncb 93 | *.opendb 94 | *.opensdf 95 | *.sdf 96 | *.cachefile 97 | *.VC.db 98 | *.VC.VC.opendb 99 | 100 | # Visual Studio profiler 101 | *.psess 102 | *.vsp 103 | *.vspx 104 | *.sap 105 | 106 | # TFS 2012 Local Workspace 107 | $tf/ 108 | 109 | # Guidance Automation Toolkit 110 | *.gpState 111 | 112 | # ReSharper is a .NET coding add-in 113 | _ReSharper*/ 114 | *.[Rr]e[Ss]harper 115 | *.DotSettings.user 116 | 117 | # JustCode is a .NET coding add-in 118 | .JustCode 119 | 120 | # TeamCity is a build add-in 121 | _TeamCity* 122 | 123 | # DotCover is a Code Coverage Tool 124 | *.dotCover 125 | 126 | # Visual Studio code coverage results 127 | *.coverage 128 | *.coveragexml 129 | 130 | # NCrunch 131 | _NCrunch_* 132 | .*crunch*.local.xml 133 | nCrunchTemp_* 134 | 135 | # MightyMoose 136 | *.mm.* 137 | AutoTest.Net/ 138 | 139 | # Web workbench (sass) 140 | .sass-cache/ 141 | 142 | # Installshield output folder 143 | [Ee]xpress/ 144 | 145 | # DocProject is a documentation generator add-in 146 | DocProject/buildhelp/ 147 | DocProject/Help/*.HxT 148 | DocProject/Help/*.HxC 149 | DocProject/Help/*.hhc 150 | DocProject/Help/*.hhk 151 | DocProject/Help/*.hhp 152 | DocProject/Help/Html2 153 | DocProject/Help/html 154 | 155 | # Click-Once directory 156 | publish/ 157 | 158 | # Publish Web Output 159 | *.[Pp]ublish.xml 160 | *.azurePubxml 161 | # TODO: Comment the next line if you want to checkin your web deploy settings 162 | # but database connection strings (with potential passwords) will be unencrypted 163 | *.pubxml 164 | *.publishproj 165 | 166 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 167 | # checkin your Azure Web App publish settings, but sensitive information contained 168 | # in these scripts will be unencrypted 169 | PublishScripts/ 170 | 171 | # NuGet Packages 172 | *.nupkg 173 | # The packages folder can be ignored because of Package Restore 174 | **/packages/* 175 | # except build/, which is used as an MSBuild target. 176 | !**/packages/build/ 177 | # Uncomment if necessary however generally it will be regenerated when needed 178 | #!**/packages/repositories.config 179 | # NuGet v3's project.json files produces more ignorable files 180 | *.nuget.props 181 | *.nuget.targets 182 | 183 | # Microsoft Azure Build Output 184 | csx/ 185 | *.build.csdef 186 | 187 | # Microsoft Azure Emulator 188 | ecf/ 189 | rcf/ 190 | 191 | # Windows Store app package directories and files 192 | AppPackages/ 193 | BundleArtifacts/ 194 | Package.StoreAssociation.xml 195 | _pkginfo.txt 196 | 197 | # Visual Studio cache files 198 | # files ending in .cache can be ignored 199 | *.[Cc]ache 200 | # but keep track of directories ending in .cache 201 | !*.[Cc]ache/ 202 | 203 | # Others 204 | ClientBin/ 205 | ~$* 206 | *~ 207 | *.dbmdl 208 | *.dbproj.schemaview 209 | *.jfm 210 | *.pfx 211 | *.publishsettings 212 | orleans.codegen.cs 213 | 214 | # Since there are multiple workflows, uncomment next line to ignore bower_components 215 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 216 | #bower_components/ 217 | 218 | # RIA/Silverlight projects 219 | Generated_Code/ 220 | 221 | # Backup & report files from converting an old project file 222 | # to a newer Visual Studio version. Backup files are not needed, 223 | # because we have git ;-) 224 | _UpgradeReport_Files/ 225 | Backup*/ 226 | UpgradeLog*.XML 227 | UpgradeLog*.htm 228 | 229 | # SQL Server files 230 | *.mdf 231 | *.ldf 232 | 233 | # Business Intelligence projects 234 | *.rdl.data 235 | *.bim.layout 236 | *.bim_*.settings 237 | 238 | # Microsoft Fakes 239 | FakesAssemblies/ 240 | 241 | # GhostDoc plugin setting file 242 | *.GhostDoc.xml 243 | 244 | # Node.js Tools for Visual Studio 245 | .ntvs_analysis.dat 246 | node_modules/ 247 | 248 | # Typescript v1 declaration files 249 | typings/ 250 | 251 | # Visual Studio 6 build log 252 | *.plg 253 | 254 | # Visual Studio 6 workspace options file 255 | *.opt 256 | 257 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 258 | *.vbw 259 | 260 | # Visual Studio LightSwitch build output 261 | **/*.HTMLClient/GeneratedArtifacts 262 | **/*.DesktopClient/GeneratedArtifacts 263 | **/*.DesktopClient/ModelManifest.xml 264 | **/*.Server/GeneratedArtifacts 265 | **/*.Server/ModelManifest.xml 266 | _Pvt_Extensions 267 | 268 | # Paket dependency manage 269 | .paket/paket.exe 270 | paket-files/ 271 | 272 | # FAKE - F# Make 273 | .fake/ 274 | 275 | # JetBrains Rider 276 | .idea/ 277 | *.sln.iml 278 | 279 | # CodeRush 280 | .cr/ 281 | 282 | # Python Tools for Visual Studio (PTVS) 283 | __pycache__/ 284 | *.pyc 285 | 286 | # Cake - Uncomment if you are using it 287 | # tools/** 288 | # !tools/packages.config 289 | 290 | # Telerik's JustMock configuration file 291 | *.jmconfig 292 | 293 | # BizTalk build output 294 | *.btp.cs 295 | *.btm.cs 296 | *.odx.cs 297 | *.xsd.cs 298 | 299 | #DS Store 300 | .DS_Store 301 | 302 | #ENV 303 | .env 304 | 305 | #From Vite 306 | # Logs 307 | logs 308 | *.log 309 | npm-debug.log* 310 | yarn-debug.log* 311 | yarn-error.log* 312 | pnpm-debug.log* 313 | lerna-debug.log* 314 | 315 | node_modules 316 | dist 317 | dist-ssr 318 | *.local 319 | 320 | # Editor directories and files 321 | .vscode/* 322 | !.vscode/extensions.json 323 | .idea 324 | .DS_Store 325 | *.suo 326 | *.ntvs* 327 | *.njsproj 328 | *.sln 329 | *.sw? 330 | .vite/ 331 | -------------------------------------------------------------------------------- /DocAssets/dead-job-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdevito3/TaskTower/ad7078a780e7c7f1ccc890a93de85451fad3b0b0/DocAssets/dead-job-view.png -------------------------------------------------------------------------------- /DocAssets/jobs-worklist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdevito3/TaskTower/ad7078a780e7c7f1ccc890a93de85451fad3b0b0/DocAssets/jobs-worklist.png -------------------------------------------------------------------------------- /DocAssets/success-job-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdevito3/TaskTower/ad7078a780e7c7f1ccc890a93de85451fad3b0b0/DocAssets/success-job-view.png -------------------------------------------------------------------------------- /HelloPgPubSub/Consts.cs: -------------------------------------------------------------------------------- 1 | namespace HelloPgPubSub; 2 | 3 | public class Consts 4 | { 5 | public const string ConnectionString = 6 | "Host=localhost;Port=51554;Database=dev_hello_pg_pub_sub;Username=postgres;Password=postgres"; 7 | } -------------------------------------------------------------------------------- /HelloPgPubSub/Database.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 2 | 3 | namespace HelloPgPubSub; 4 | 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | public record JobData(string Payload); 8 | 9 | public sealed record Job 10 | { 11 | public Guid Id { get; set; } 12 | public string Status { get; set; } 13 | public string Payload { get; set; } 14 | public DateTime? CreatedAt { get; set; } = DateTime.UtcNow; 15 | } 16 | 17 | public class ApplicationDbContext(DbContextOptions options) 18 | : DbContext(options) 19 | { 20 | public DbSet Jobs { get; set; } 21 | 22 | protected override void OnModelCreating(ModelBuilder modelBuilder) 23 | { 24 | base.OnModelCreating(modelBuilder); 25 | modelBuilder.ApplyConfiguration(new JobConfiguration()); 26 | } 27 | } 28 | 29 | public sealed class JobConfiguration : IEntityTypeConfiguration 30 | { 31 | public void Configure(EntityTypeBuilder builder) 32 | { 33 | builder.Property(x => x.Payload) 34 | .HasColumnType("jsonb"); 35 | } 36 | } -------------------------------------------------------------------------------- /HelloPgPubSub/HelloPgPubSub.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | all 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /HelloPgPubSub/JobNotificationListener.cs: -------------------------------------------------------------------------------- 1 | namespace HelloPgPubSub; 2 | 3 | using Microsoft.Extensions.Hosting; 4 | using Npgsql; 5 | using System; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Serilog; 9 | 10 | // public class JobNotificationListener : BackgroundService 11 | // { 12 | // protected override async Task ExecuteAsync(CancellationToken stoppingToken) 13 | // { 14 | // await using var conn = new NpgsqlConnection(Consts.ConnectionString); 15 | // await conn.OpenAsync(stoppingToken); 16 | // 17 | // await using (var cmd = new NpgsqlCommand("LISTEN job_available", conn)) 18 | // { 19 | // await cmd.ExecuteNonQueryAsync(stoppingToken); 20 | // } 21 | // 22 | // conn.Notification += (_, e) => 23 | // { 24 | // // Console.WriteLine($"Notification received: Job available with ID {e.Payload}"); 25 | // Log.Information("Notification received: Job available with ID {JobId}", e.Payload); 26 | // 27 | // }; 28 | // 29 | // while (!stoppingToken.IsCancellationRequested) 30 | // { 31 | // await conn.WaitAsync(stoppingToken); 32 | // } 33 | // } 34 | // } 35 | 36 | using Dapper; 37 | 38 | public class JobNotificationListener : BackgroundService 39 | { 40 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 41 | { 42 | await using var conn = new NpgsqlConnection(Consts.ConnectionString); 43 | await conn.OpenAsync(stoppingToken); 44 | 45 | await using (var cmd = new NpgsqlCommand("LISTEN job_available", conn)) 46 | { 47 | await cmd.ExecuteNonQueryAsync(stoppingToken); 48 | } 49 | 50 | conn.Notification += async (_, e) => 51 | { 52 | Log.Information("Notification received: Job available with ID {JobId}", e.Payload); 53 | await ProcessJob(stoppingToken); 54 | }; 55 | 56 | while (!stoppingToken.IsCancellationRequested) 57 | { 58 | // This call is blocking until a notification is received 59 | await conn.WaitAsync(stoppingToken); 60 | } 61 | } 62 | 63 | private async Task ProcessJob(CancellationToken stoppingToken) 64 | { 65 | await using var conn = new NpgsqlConnection(Consts.ConnectionString); 66 | await conn.OpenAsync(stoppingToken); 67 | 68 | await using var tx = await conn.BeginTransactionAsync(stoppingToken); 69 | // Fetch the next available job that is not already locked by another process 70 | var job = await conn.QueryFirstOrDefaultAsync( 71 | @" 72 | SELECT id, payload 73 | FROM jobs 74 | WHERE status = 'pending' 75 | ORDER BY created_at 76 | FOR UPDATE SKIP LOCKED 77 | LIMIT 1", 78 | transaction: tx 79 | ); 80 | 81 | if (job != null) 82 | { 83 | Log.Information($"Processing job {job.Id} with payload {job.Payload}"); 84 | // Process the job here 85 | var updateResult = await conn.ExecuteAsync( 86 | "UPDATE jobs SET status = 'completed' WHERE id = @Id", 87 | new { job.Id }, 88 | transaction: tx 89 | ); 90 | } 91 | 92 | await tx.CommitAsync(stoppingToken); 93 | } 94 | } 95 | 96 | -------------------------------------------------------------------------------- /HelloPgPubSub/MigrationHostedService.cs: -------------------------------------------------------------------------------- 1 | namespace RecipeManagement.Databases; 2 | 3 | using System.Net.Sockets; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | using Serilog; 10 | using Npgsql; 11 | 12 | public class MigrationHostedService( 13 | IServiceScopeFactory scopeFactory) 14 | : IHostedService 15 | where TDbContext : DbContext 16 | { 17 | private readonly ILogger _logger = Log.ForContext>(); 18 | 19 | public async Task StartAsync(CancellationToken cancellationToken) 20 | { 21 | try 22 | { 23 | _logger.Information("Applying migrations for {DbContext}", typeof(TDbContext).Name); 24 | 25 | await using var scope = scopeFactory.CreateAsyncScope(); 26 | var context = scope.ServiceProvider.GetRequiredService(); 27 | await context.Database.MigrateAsync(cancellationToken); 28 | 29 | _logger.Information("Migrations complete for {DbContext}", typeof(TDbContext).Name); 30 | } 31 | catch (Exception ex) when (ex is SocketException or NpgsqlException) 32 | { 33 | _logger.Error(ex, "Could not connect to the database. Please check the connection string and make sure the database is running."); 34 | throw; 35 | } 36 | catch (Exception ex) 37 | { 38 | _logger.Error(ex, "An error occurred while applying the database migrations."); 39 | throw; 40 | } 41 | } 42 | 43 | public Task StopAsync(CancellationToken cancellationToken) 44 | { 45 | return Task.CompletedTask; 46 | } 47 | } -------------------------------------------------------------------------------- /HelloPgPubSub/Migrations/20240205015545_initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using HelloPgPubSub; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 | 10 | #nullable disable 11 | 12 | namespace HelloPgPubSub.Migrations 13 | { 14 | [DbContext(typeof(ApplicationDbContext))] 15 | [Migration("20240205015545_initial")] 16 | partial class initial 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "8.0.0") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("HelloPgPubSub.Job", b => 29 | { 30 | b.Property("Id") 31 | .ValueGeneratedOnAdd() 32 | .HasColumnType("uuid") 33 | .HasColumnName("id"); 34 | 35 | b.Property("CreatedAt") 36 | .HasColumnType("timestamp with time zone") 37 | .HasColumnName("created_at"); 38 | 39 | b.Property("Payload") 40 | .IsRequired() 41 | .HasColumnType("jsonb") 42 | .HasColumnName("payload"); 43 | 44 | b.Property("Status") 45 | .IsRequired() 46 | .HasColumnType("text") 47 | .HasColumnName("status"); 48 | 49 | b.HasKey("Id") 50 | .HasName("pk_jobs"); 51 | 52 | b.ToTable("jobs", (string)null); 53 | }); 54 | #pragma warning restore 612, 618 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /HelloPgPubSub/Migrations/20240205015545_initial.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace HelloPgPubSub.Migrations 7 | { 8 | /// 9 | public partial class initial : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "jobs", 16 | columns: table => new 17 | { 18 | id = table.Column(type: "uuid", nullable: false), 19 | status = table.Column(type: "text", nullable: false), 20 | payload = table.Column(type: "jsonb", nullable: false), 21 | created_at = table.Column(type: "timestamp with time zone", nullable: true) 22 | }, 23 | constraints: table => 24 | { 25 | table.PrimaryKey("pk_jobs", x => x.id); 26 | }); 27 | 28 | migrationBuilder.Sql($@"CREATE OR REPLACE FUNCTION notify_job_available() 29 | RETURNS trigger AS $$ 30 | BEGIN 31 | PERFORM pg_notify('job_available', NEW.id::text); 32 | RETURN NEW; 33 | END; 34 | $$ LANGUAGE plpgsql; 35 | 36 | CREATE TRIGGER job_available_trigger 37 | AFTER INSERT ON jobs 38 | FOR EACH ROW EXECUTE FUNCTION notify_job_available();"); 39 | } 40 | 41 | /// 42 | protected override void Down(MigrationBuilder migrationBuilder) 43 | { 44 | migrationBuilder.DropTable( 45 | name: "jobs"); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /HelloPgPubSub/Migrations/ApplicationDbContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using HelloPgPubSub; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | 9 | #nullable disable 10 | 11 | namespace HelloPgPubSub.Migrations 12 | { 13 | [DbContext(typeof(ApplicationDbContext))] 14 | partial class ApplicationDbContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "8.0.0") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("HelloPgPubSub.Job", b => 26 | { 27 | b.Property("Id") 28 | .ValueGeneratedOnAdd() 29 | .HasColumnType("uuid") 30 | .HasColumnName("id"); 31 | 32 | b.Property("CreatedAt") 33 | .HasColumnType("timestamp with time zone") 34 | .HasColumnName("created_at"); 35 | 36 | b.Property("Payload") 37 | .IsRequired() 38 | .HasColumnType("jsonb") 39 | .HasColumnName("payload"); 40 | 41 | b.Property("Status") 42 | .IsRequired() 43 | .HasColumnType("text") 44 | .HasColumnName("status"); 45 | 46 | b.HasKey("Id") 47 | .HasName("pk_jobs"); 48 | 49 | b.ToTable("jobs", (string)null); 50 | }); 51 | #pragma warning restore 612, 618 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /HelloPgPubSub/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Dapper; 3 | using HelloPgPubSub; 4 | using Microsoft.EntityFrameworkCore; 5 | using Npgsql; 6 | using RecipeManagement.Databases; 7 | using Serilog; 8 | using Serilog.Events; 9 | using Serilog.Sinks.SystemConsole.Themes; 10 | 11 | var builder = WebApplication.CreateBuilder(args); 12 | Log.Logger = new LoggerConfiguration() 13 | .ReadFrom.Configuration(builder.Configuration) 14 | .MinimumLevel.Override("MassTransit", LogEventLevel.Debug) 15 | .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) 16 | .MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Information) 17 | .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) 18 | .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Warning) 19 | .Enrich.WithProperty("ApplicationName", builder.Environment.ApplicationName) 20 | .Enrich.FromLogContext() 21 | // .Destructure.UsingAttributes() 22 | .WriteTo.Console(theme: AnsiConsoleTheme.Code) 23 | .CreateLogger(); 24 | builder.Host.UseSerilog(); 25 | 26 | builder.Services.AddDbContext(options => 27 | options.UseNpgsql(Consts.ConnectionString, 28 | b => b.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)) 29 | .UseSnakeCaseNamingConvention()); 30 | builder.Services.AddHostedService>(); 31 | builder.Services.AddHostedService(); 32 | 33 | builder.Services.AddEndpointsApiExplorer(); 34 | builder.Services.AddSwaggerGen(); 35 | 36 | var app = builder.Build(); 37 | app.UseSwagger(); 38 | app.UseSwaggerUI(); 39 | app.UseHttpsRedirection(); 40 | 41 | app.MapPost("/create-job", async (JobData request, HttpContext http, ApplicationDbContext context) => 42 | { 43 | if (string.IsNullOrWhiteSpace(request.Payload)) 44 | { 45 | return Results.BadRequest("Invalid job payload."); 46 | } 47 | 48 | var job = new Job 49 | { 50 | Status = "pending", 51 | Payload = JsonSerializer.Serialize(request) 52 | }; 53 | 54 | try 55 | { 56 | context.Jobs.Add(job); 57 | await context.SaveChangesAsync(); 58 | 59 | return Results.Ok(new { Message = $"Job created with ID: {job.Id}" }); 60 | } 61 | catch (Exception ex) 62 | { 63 | var logger = http.RequestServices.GetRequiredService>(); 64 | logger.LogError(ex, "Error creating job: {Message}", ex.Message); 65 | return Results.Problem("An error occurred while creating the job."); 66 | } 67 | }); 68 | 69 | app.MapPost("/create-many-jobs", async (HttpContext http, ApplicationDbContext context) => 70 | { 71 | try 72 | { 73 | for (var i = 0; i < 100; i++) 74 | { 75 | var job = new Job 76 | { 77 | Status = "pending", 78 | Payload = JsonSerializer.Serialize(Guid.NewGuid()) 79 | }; 80 | context.Jobs.Add(job); 81 | // Log.Information("Job in EF with ID: {Id}", job.Id); 82 | } 83 | 84 | await context.SaveChangesAsync(); 85 | return Results.Ok(new { Message = $"Jobs created" }); 86 | } 87 | catch (Exception ex) 88 | { 89 | var logger = http.RequestServices.GetRequiredService>(); 90 | logger.LogError(ex, "Error creating job: {Message}", ex.Message); 91 | return Results.Problem("An error occurred while creating the job."); 92 | } 93 | }); 94 | 95 | app.Run(); 96 | -------------------------------------------------------------------------------- /HelloPgPubSub/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:8073", 8 | "sslPort": 44368 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5147", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7218;http://localhost:5147", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /HelloPgPubSub/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /TaskTower/Configurations/CorsServiceExtension.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Configurations; 2 | 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | 7 | internal static class CorsServiceExtension 8 | { 9 | // public static IServiceCollection AddCorsService(this IServiceCollection services, string policyName, IWebHostEnvironment env) 10 | public static IServiceCollection AddCorsService(this IServiceCollection services, string policyName) 11 | { 12 | // if (env.IsDevelopment()) 13 | // { 14 | services.AddCors(options => 15 | { 16 | options.AddPolicy(policyName, builder => 17 | builder.SetIsOriginAllowed(_ => true) 18 | .AllowAnyMethod() 19 | .AllowAnyHeader() 20 | .AllowCredentials() 21 | .WithExposedHeaders("X-Pagination")); 22 | }); 23 | // } 24 | // else 25 | // { 26 | // //TODO update origins here with env vars or secret 27 | // //services.AddCors(options => 28 | // //{ 29 | // // options.AddPolicy(policyName, builder => 30 | // // builder.WithOrigins(origins) 31 | // // .AllowAnyMethod() 32 | // // .AllowAnyHeader() 33 | // // .WithExposedHeaders("X-Pagination")); 34 | // //}); 35 | // } 36 | 37 | return services; 38 | } 39 | } -------------------------------------------------------------------------------- /TaskTower/Configurations/TaskTowerOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Configurations; 2 | 3 | using Domain.InterceptionStages; 4 | using Domain.QueuePrioritizations; 5 | using Interception; 6 | 7 | public class TaskTowerOptions 8 | { 9 | /// 10 | /// Gets or sets the total number of backend processes available to process jobs. 11 | /// By default, this is set to the number of runtime CPUs available to the current process, 12 | /// leveraging . 13 | /// 14 | public int BackendConcurrency { get; set; } = Environment.ProcessorCount; 15 | 16 | /// 17 | /// String containing connection details for the backend 18 | /// 19 | public string ConnectionString { get; set; } = null!; 20 | 21 | /// 22 | /// The schema to use for the task tower tables 23 | /// 24 | public string Schema { get; set; } = "task_tower"; 25 | 26 | /// 27 | /// The interval of time between checking for new future/retry jobs that need to be enqueued 28 | /// 29 | public TimeSpan JobCheckInterval { get; set; } = TimeSpan.FromSeconds(1); 30 | 31 | /// 32 | /// The interval of time that the the queue will be scanned for new jobs to announce 33 | /// 34 | public TimeSpan QueueAnnouncementInterval { get; set; } = TimeSpan.FromSeconds(1); 35 | 36 | /// 37 | /// The number of milliseconds a postgres transaction may idle before the connection is killed 38 | /// 39 | public int IdleTransactionTimeout { get; set; } = 30000; 40 | 41 | /// 42 | /// Outlines the queues that will be used along their respective priorities 43 | /// 44 | public Dictionary QueuePriorities { get; set; } = new Dictionary(); 45 | 46 | /// 47 | /// The method of prioritizing jobs in the queue 48 | /// 49 | public QueuePrioritization QueuePrioritization { get; set; } = QueuePrioritization.None(); 50 | 51 | public Dictionary JobConfigurations { get; private set; } = new Dictionary(); 52 | 53 | /// 54 | /// A record of the different message types and their respective queues 55 | /// 56 | public Dictionary QueueAssignments 57 | => JobConfigurations.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Queue); 58 | 59 | public int? GetMaxRetryCount(Type? type) 60 | { 61 | if (type != null && JobConfigurations.TryGetValue(type, out var config)) 62 | return config.MaxRetryCount; 63 | 64 | return null; 65 | } 66 | 67 | public string? GetJobName(Type? type) 68 | { 69 | if (type != null && JobConfigurations.TryGetValue(type, out var config)) 70 | return config.DisplayName; 71 | 72 | return null; 73 | } 74 | 75 | public List GetInterceptors(Type? type, InterceptionStage stage) 76 | { 77 | var interceptorTypes = new List(); 78 | if(stage == InterceptionStage.PreProcessing()) 79 | { 80 | var runnerContextInterceptorType = typeof(TaskTowerRunnerContextInterceptor); 81 | interceptorTypes.Add(runnerContextInterceptorType); 82 | } 83 | 84 | if (type != null && JobConfigurations.TryGetValue(type, out var config)) 85 | interceptorTypes.AddRange(config.JobInterceptors 86 | .Where(interceptor => interceptor.Stage == stage) 87 | .Select(interceptor => interceptor.InterceptorType) 88 | .ToList()); 89 | 90 | return interceptorTypes; 91 | } 92 | public JobConfiguration AddJobConfiguration() 93 | { 94 | var config = new JobConfiguration(); 95 | JobConfigurations[typeof(T)] = config; 96 | return config; // This assumes you want to directly return the JobConfiguration for chaining 97 | } 98 | 99 | 100 | public class JobConfiguration 101 | { 102 | public string? Queue { get; private set; } 103 | public string? DisplayName { get; private set; } 104 | public int? MaxRetryCount { get; private set; } 105 | 106 | public List JobInterceptors { get; private set; } = new List(); 107 | 108 | // Enables fluent configuration by returning 'this' 109 | public JobConfiguration SetQueue(string queue) 110 | { 111 | Queue = queue; 112 | return this; 113 | } 114 | 115 | public JobConfiguration SetDisplayName(string displayName) 116 | { 117 | DisplayName = displayName; 118 | return this; 119 | } 120 | 121 | public JobConfiguration SetMaxRetryCount(int maxRetryCount) 122 | { 123 | MaxRetryCount = maxRetryCount; 124 | return this; 125 | } 126 | 127 | public JobConfiguration WithPreProcessingInterceptor() where TJobInterceptor : JobInterceptor 128 | { 129 | JobInterceptors.Add(new InterceptorAssignment(typeof(TJobInterceptor), InterceptionStage.PreProcessing())); 130 | return this; 131 | } 132 | 133 | public JobConfiguration WithDeathInterceptor() where TJobInterceptor : JobInterceptor 134 | { 135 | JobInterceptors.Add(new InterceptorAssignment(typeof(TJobInterceptor), InterceptionStage.Death())); 136 | return this; 137 | } 138 | } 139 | 140 | public record InterceptorAssignment(Type InterceptorType, InterceptionStage Stage); 141 | } 142 | -------------------------------------------------------------------------------- /TaskTower/Configurations/TaskTowerServiceRegistration.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Configurations; 2 | 3 | using Controllers.v1; 4 | using Dapper; 5 | using Database; 6 | using Domain.JobStatuses; 7 | using Domain.JobStatuses.Mappings; 8 | using Domain.RunHistories.Services; 9 | using Domain.TaskTowerJob.Features; 10 | using Domain.TaskTowerJob.Services; 11 | using FluentMigrator.Runner; 12 | using Interception; 13 | using Microsoft.AspNetCore.Hosting; 14 | using Microsoft.AspNetCore.Mvc.ApplicationParts; 15 | using Microsoft.EntityFrameworkCore; 16 | using Microsoft.Extensions.Configuration; 17 | using Microsoft.Extensions.DependencyInjection; 18 | using Microsoft.Extensions.Hosting; 19 | using Microsoft.Extensions.Options; 20 | using Processing; 21 | 22 | public delegate void ConfigureTaskTowerOptions(IServiceProvider serviceProvider, TaskTowerOptions options); 23 | 24 | public static class TaskTowerServiceRegistration 25 | { 26 | /// 27 | /// Adds TaskTower service to the service collection. 28 | /// 29 | public static IServiceCollection AddTaskTower(this IServiceCollection services, IConfiguration configuration, 30 | Action? configureOptions = null) 31 | { 32 | if (configureOptions != null) 33 | { 34 | services.Configure(options => 35 | { 36 | configuration.GetSection("TaskTowerOptions").Bind(options); 37 | configureOptions(options); 38 | }); 39 | } 40 | else 41 | { 42 | services.Configure(configuration.GetSection("TaskTowerOptions")); 43 | } 44 | 45 | var options = configuration.GetSection("TaskTowerOptions").Get(); 46 | if (configureOptions != null) 47 | { 48 | var tempOptions = new TaskTowerOptions(); 49 | configuration.GetSection("TaskTowerOptions").Bind(tempOptions); 50 | configureOptions(tempOptions); 51 | options = tempOptions; 52 | } 53 | 54 | SqlMapper.AddTypeHandler(typeof(JobStatus), new JobStatusTypeHandler()); 55 | 56 | services.AddScoped(); 57 | services.AddScoped(); 58 | services.AddScoped(); 59 | services.AddScoped(); 60 | services.AddScoped(); 61 | 62 | MigrationConfig.SchemaName = options!.Schema; 63 | services.AddFluentMigratorCore() 64 | .ConfigureRunner(rb => rb 65 | .AddPostgres() 66 | .WithGlobalConnectionString(options!.ConnectionString) 67 | .ScanIn(typeof(TaskTowerServiceRegistration).Assembly).For.Migrations()) 68 | // .AddLogging(lb => lb.AddFluentMigratorConsole()) 69 | .BuildServiceProvider(false); 70 | 71 | services.AddCorsService("TaskTowerCorsPolicy"); 72 | services.AddHostedService(); 73 | services.AddHostedService(); 74 | 75 | services.AddControllers().PartManager.ApplicationParts.Add(new AssemblyPart(typeof(JobsController).Assembly)); 76 | 77 | return services; 78 | } 79 | } -------------------------------------------------------------------------------- /TaskTower/Configurations/TaskTowerUiMiddlewareExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Configurations; 2 | 3 | using System.Reflection; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Http; 6 | 7 | public static class TaskTowerUiMiddlewareExtensions 8 | { 9 | public static IApplicationBuilder UseTaskTowerUi(this IApplicationBuilder app) 10 | { 11 | app.UseCors("TaskTowerCorsPolicy"); 12 | app.UseMiddleware(); 13 | app.UseRouting(); 14 | app.UseEndpoints(endpoints => 15 | { 16 | endpoints.MapControllers(); 17 | }); 18 | return app; 19 | } 20 | } 21 | 22 | public class TaskTowerUiMiddleware 23 | { 24 | private readonly RequestDelegate _next; 25 | 26 | public TaskTowerUiMiddleware(RequestDelegate next) 27 | { 28 | _next = next; 29 | } 30 | 31 | public async Task Invoke(HttpContext context) 32 | { 33 | var path = context.Request.Path.Value.TrimStart('/'); 34 | 35 | // Check if the request is for the custom UI or its assets 36 | if (context.Request.Path.StartsWithSegments($"/{TaskTowerConstants.TaskTowerUiRoot}", StringComparison.OrdinalIgnoreCase) 37 | || context.Request.Path.StartsWithSegments("/assets", StringComparison.OrdinalIgnoreCase)) 38 | { 39 | 40 | if (path.StartsWith(TaskTowerConstants.TaskTowerUiRoot)) 41 | { 42 | path = path.Substring(TaskTowerConstants.TaskTowerUiRoot.Length).TrimStart('/'); 43 | } 44 | 45 | if (string.IsNullOrEmpty(path)) 46 | { 47 | path = "index.html"; 48 | } 49 | 50 | var resourceName = $"{TaskTowerConstants.UiEmbeddedFileNamespace}.{path.Replace("/", ".")}"; 51 | 52 | var assembly = Assembly.GetExecutingAssembly(); 53 | var resourceStream = assembly.GetManifestResourceStream(resourceName); 54 | 55 | if (resourceStream != null) 56 | { 57 | // Determine the content type 58 | var contentType = GetContentType(path); 59 | context.Response.ContentType = contentType; 60 | 61 | if (path.Equals("index.html", StringComparison.OrdinalIgnoreCase)) 62 | { 63 | using var reader = new StreamReader(resourceStream); 64 | var content = await reader.ReadToEndAsync(); 65 | 66 | // Here you inject the environment variable value into the placeholder in your index.html 67 | var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); 68 | content = content.Replace("{{ASPNETCORE_ENVIRONMENT}}", environment); 69 | 70 | await context.Response.WriteAsync(content); 71 | return; // Ensure you exit after handling the response 72 | } 73 | 74 | await resourceStream.CopyToAsync(context.Response.Body); 75 | } 76 | else 77 | { 78 | // Log the missing resource for debugging purposes 79 | // var logger = context.RequestServices.GetService>(); 80 | // logger?.LogWarning($"Resource not found: {resourceName}"); 81 | 82 | // If the resource is not found, you can decide to serve a 404 page or just set the status code. 83 | context.Response.StatusCode = StatusCodes.Status404NotFound; 84 | } 85 | } 86 | else 87 | { 88 | // Continue the middleware pipeline for other requests 89 | await _next(context); 90 | } 91 | } 92 | 93 | private string GetContentType(string path) 94 | { 95 | return path switch 96 | { 97 | var p when p.EndsWith(".html", StringComparison.OrdinalIgnoreCase) => "text/html", 98 | var p when p.EndsWith(".js", StringComparison.OrdinalIgnoreCase) => "application/javascript", 99 | var p when p.EndsWith(".css", StringComparison.OrdinalIgnoreCase) => "text/css", 100 | var p when p.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) => "image/svg+xml", 101 | _ => "application/octet-stream" 102 | }; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /TaskTower/Controllers/v1/JobsController.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Controllers.v1; 2 | 3 | using System.Text.Json; 4 | using Domain.TaskTowerJob; 5 | using Microsoft.AspNetCore.Mvc; 6 | using System.Threading.Tasks; 7 | using Configurations; 8 | using Dapper; 9 | using Database; 10 | using Domain.TaskTowerJob.Dtos; 11 | using Domain.TaskTowerJob.Features; 12 | using Domain.TaskTowerJob.Services; 13 | using Microsoft.AspNetCore.Http; 14 | using Microsoft.Extensions.Options; 15 | using Npgsql; 16 | 17 | [ApiController] 18 | [Route("api/v1/jobs")] 19 | public class JobsController(ITaskTowerJobRepository taskTowerJobRepository, IJobViewer jobViewer) : ControllerBase 20 | { 21 | [HttpGet("paginated")] 22 | [ApiExplorerSettings(IgnoreApi = true)] 23 | public async Task GetPaginatedJobs([FromQuery] JobParametersDto jobParametersDto, CancellationToken cancellationToken) 24 | { 25 | var queryResponse = await taskTowerJobRepository.GetPaginatedJobs(jobParametersDto.PageNumber, 26 | jobParametersDto.PageSize, 27 | jobParametersDto.StatusFilter, 28 | jobParametersDto.QueueFilter, 29 | jobParametersDto.FilterText, 30 | cancellationToken); 31 | var dto = queryResponse.Select(x => new 32 | { 33 | Id = x.Id, 34 | Status = x.Status.Value, 35 | JobName = x.JobName, 36 | Retries = x.Retries, 37 | MaxRetries = x.MaxRetries , 38 | RunAfter = x.RunAfter, 39 | Deadline = x.Deadline, 40 | CreatedAt = x.CreatedAt, 41 | Type = x.Type, 42 | Method = x.Method, 43 | ParameterTypes = x.ParameterTypes ?? Array.Empty(), 44 | Payload = x.Payload, 45 | Queue = x.Queue, 46 | RanAt = x.RanAt, 47 | }); 48 | 49 | var paginationMetadata = new 50 | { 51 | totalCount = queryResponse.TotalCount, 52 | pageSize = queryResponse.PageSize, 53 | currentPageSize = queryResponse.CurrentPageSize, 54 | currentStartIndex = queryResponse.CurrentStartIndex, 55 | currentEndIndex = queryResponse.CurrentEndIndex, 56 | pageNumber = queryResponse.PageNumber, 57 | totalPages = queryResponse.TotalPages, 58 | hasPrevious = queryResponse.HasPrevious, 59 | hasNext = queryResponse.HasNext 60 | }; 61 | 62 | Response.Headers.Append("X-Pagination", 63 | JsonSerializer.Serialize(paginationMetadata)); 64 | 65 | return Ok(dto); 66 | } 67 | 68 | [HttpGet("queueNames")] 69 | [ApiExplorerSettings(IgnoreApi = true)] 70 | public async Task GetQueueNames() 71 | { 72 | var queueNames = await taskTowerJobRepository.GetQueueNames(); 73 | return Ok(queueNames); 74 | } 75 | 76 | public sealed record BulkDeleteJobsRequest(Guid[] JobIds); 77 | [HttpPost("bulkDelete")] 78 | [ApiExplorerSettings(IgnoreApi = true)] 79 | public async Task BulkDeleteJobs([FromBody] BulkDeleteJobsRequest request) 80 | { 81 | await taskTowerJobRepository.BulkDeleteJobs(request.JobIds); 82 | return NoContent(); 83 | } 84 | 85 | [HttpGet("{jobId:guid}/view")] 86 | [ApiExplorerSettings(IgnoreApi = true)] 87 | public async Task GetJobView(Guid jobId) 88 | { 89 | var jobView = await jobViewer.GetJobView(jobId); 90 | return Ok(jobView); 91 | } 92 | 93 | [HttpPut("{jobId:guid}/requeue")] 94 | [ApiExplorerSettings(IgnoreApi = true)] 95 | public async Task RequeueJob(Guid jobId, CancellationToken cancellationToken = default) 96 | { 97 | await taskTowerJobRepository.RequeueJob(jobId, cancellationToken); 98 | return NoContent(); 99 | } 100 | } -------------------------------------------------------------------------------- /TaskTower/Database/MigrationConfig.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Database; 2 | 3 | public static class MigrationConfig 4 | { 5 | public static string SchemaName { get; set; } = "task_tower"; 6 | } -------------------------------------------------------------------------------- /TaskTower/Database/MigrationHostedService.cs: -------------------------------------------------------------------------------- 1 | using FluentMigrator.Runner; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Extensions.Logging; 5 | using System; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace TaskTower.Database 10 | { 11 | public class MigrationHostedService : IHostedService 12 | { 13 | private readonly ILogger _logger; 14 | private readonly IServiceScopeFactory _scopeFactory; 15 | 16 | public MigrationHostedService(IServiceScopeFactory scopeFactory, ILogger logger) 17 | { 18 | _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); 19 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 20 | } 21 | 22 | public Task StartAsync(CancellationToken cancellationToken) 23 | { 24 | return Task.Run(() => 25 | { 26 | try 27 | { 28 | _logger.LogInformation("Applying migrations using FluentMigrator"); 29 | 30 | using (var scope = _scopeFactory.CreateScope()) 31 | { 32 | var runner = scope.ServiceProvider.GetRequiredService(); 33 | 34 | // Validate the migrations before applying 35 | runner.ListMigrations(); 36 | 37 | // Apply the migrations 38 | runner.MigrateUp(); 39 | 40 | _logger.LogInformation("Migrations applied successfully using FluentMigrator"); 41 | } 42 | } 43 | catch (Exception ex) 44 | { 45 | _logger.LogError(ex, "An error occurred while applying the database migrations using FluentMigrator."); 46 | throw; 47 | } 48 | }, cancellationToken); 49 | } 50 | 51 | public Task StopAsync(CancellationToken cancellationToken) 52 | { 53 | // No operation on stop as migrations are only applied at the start 54 | return Task.CompletedTask; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /TaskTower/Database/Migrations/Initial.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Database.Migrations; 2 | 3 | using System.Data; 4 | using FluentMigrator; 5 | 6 | [Migration(20240227)] 7 | public class Initial : Migration 8 | { 9 | public override void Up() 10 | { 11 | var schemaName = MigrationConfig.SchemaName; 12 | Execute.Sql($"CREATE SCHEMA IF NOT EXISTS {schemaName}"); 13 | 14 | Create.Table("jobs") 15 | .InSchema(schemaName) 16 | .WithColumn("id").AsGuid().PrimaryKey() 17 | .WithColumn("queue").AsString().Nullable() 18 | .WithColumn("status").AsString().NotNullable() 19 | .WithColumn("type").AsString().NotNullable() 20 | .WithColumn("method").AsString().NotNullable() 21 | .WithColumn("job_name").AsString().Nullable() 22 | .WithColumn("parameter_types").AsCustom("text[]").NotNullable() 23 | .WithColumn("payload").AsCustom("jsonb").NotNullable() 24 | .WithColumn("retries").AsInt32().NotNullable() 25 | .WithColumn("max_retries").AsInt32().Nullable() 26 | .WithColumn("run_after").AsDateTimeOffset().NotNullable() 27 | .WithColumn("ran_at").AsDateTimeOffset().Nullable() 28 | .WithColumn("created_at").AsDateTimeOffset().NotNullable() 29 | .WithColumn("deadline").AsDateTimeOffset().Nullable() 30 | .WithColumn("context_parameters").AsCustom("jsonb").Nullable(); 31 | 32 | Create.Table("run_histories") 33 | .InSchema(schemaName) 34 | .WithColumn("id").AsGuid().PrimaryKey() 35 | .WithColumn("job_id").AsGuid().ForeignKey("FK_run_histories_job_id", schemaName, "jobs", "id").OnDelete(Rule.None) 36 | .WithColumn("status").AsString().NotNullable() 37 | .WithColumn("comment").AsString().Nullable() 38 | .WithColumn("details").AsString().Nullable() 39 | .WithColumn("occurred_at").AsDateTimeOffset().NotNullable(); 40 | 41 | Create.Table("tags") 42 | .InSchema(schemaName) 43 | .WithColumn("job_id").AsGuid().PrimaryKey().ForeignKey("FK_tags_job_id", schemaName, "jobs", "id").OnDelete(Rule.None) 44 | .WithColumn("name").AsString().PrimaryKey(); 45 | 46 | Create.Index("ix_jobs_run_after").OnTable("jobs").InSchema(schemaName).OnColumn("run_after"); 47 | Create.Index("ix_jobs_status").OnTable("jobs").InSchema(schemaName).OnColumn("status"); 48 | Create.Index("ix_jobs_queue").OnTable("jobs").InSchema(schemaName).OnColumn("queue"); 49 | Create.Index("ix_jobs_id").OnTable("jobs").InSchema(schemaName).OnColumn("id"); 50 | Create.Index("ix_run_histories_job_id").OnTable("run_histories").InSchema(schemaName).OnColumn("job_id"); 51 | Create.Index("ix_run_histories_status").OnTable("run_histories").InSchema(schemaName).OnColumn("status"); 52 | Create.Index("ix_tags_name").OnTable("tags").InSchema(schemaName).OnColumn("name"); 53 | 54 | Execute.Sql($@"CREATE OR REPLACE FUNCTION {schemaName}.notify_job_available() 55 | RETURNS trigger AS $$ 56 | BEGIN 57 | PERFORM pg_notify('job_available', 'Queue: ' || NEW.queue || ', ID: ' || NEW.id::text); 58 | RETURN NEW; 59 | END; 60 | $$ LANGUAGE plpgsql;"); 61 | 62 | Execute.Sql($@"CREATE OR REPLACE FUNCTION {schemaName}.enqueue_job() 63 | RETURNS TRIGGER AS $$ 64 | BEGIN 65 | -- Update status in jobs table 66 | UPDATE {schemaName}.jobs 67 | SET status = 'Enqueued' 68 | WHERE id = NEW.id; 69 | 70 | -- Add a job history records 71 | INSERT INTO {schemaName}.run_histories(id, job_id, status, occurred_at) 72 | VALUES 73 | (gen_random_uuid(), NEW.id, 'Pending', NOW()), 74 | (gen_random_uuid(), NEW.id, 'Enqueued', NOW()); 75 | 76 | RETURN NEW; 77 | END; 78 | $$ LANGUAGE plpgsql; 79 | 80 | CREATE TRIGGER trigger_enqueue_job 81 | AFTER INSERT ON {schemaName}.jobs 82 | FOR EACH ROW 83 | WHEN (timezone('utc', NEW.run_after) <= timezone('utc', NEW.created_at)) 84 | EXECUTE FUNCTION {schemaName}.enqueue_job();"); 85 | 86 | Execute.Sql($@"CREATE OR REPLACE FUNCTION {schemaName}.add_scheduled_job_pending_history() 87 | RETURNS TRIGGER AS $$ 88 | BEGIN 89 | -- Add a job history record for pending 90 | INSERT INTO {schemaName}.run_histories(id, job_id, status, occurred_at) 91 | VALUES (gen_random_uuid(), NEW.id, 'Pending', NOW()); 92 | 93 | RETURN NEW; 94 | END; 95 | $$ LANGUAGE plpgsql; 96 | 97 | CREATE TRIGGER trigger_add_scheduled_job_pending_history 98 | AFTER INSERT ON {schemaName}.jobs 99 | FOR EACH ROW 100 | WHEN (timezone('utc', NEW.run_after) > timezone('utc', NEW.created_at)) 101 | EXECUTE FUNCTION {schemaName}.add_scheduled_job_pending_history();"); 102 | } 103 | 104 | public override void Down() 105 | { 106 | var schemaName = MigrationConfig.SchemaName; 107 | Delete.Table("jobs").InSchema(schemaName); 108 | Delete.Table("run_histories").InSchema(schemaName); 109 | Delete.Table("tags").InSchema(schemaName); 110 | 111 | Execute.Sql($@"DROP FUNCTION IF EXISTS {schemaName}.notify_job_available();"); 112 | Execute.Sql($@"DROP FUNCTION IF EXISTS {schemaName}.enqueue_job();"); 113 | Execute.Sql($@"DROP FUNCTION IF EXISTS {schemaName}.add_scheduled_job_pending_history();"); 114 | 115 | Execute.Sql($@"DROP TRIGGER IF EXISTS trigger_enqueue_job ON {schemaName}.jobs;"); 116 | Execute.Sql($@"DROP TRIGGER IF EXISTS trigger_add_scheduled_job_pending_history ON {schemaName}.jobs;"); 117 | 118 | Delete.Schema(schemaName); 119 | } 120 | } -------------------------------------------------------------------------------- /TaskTower/Domain/ContextParameter.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain; 2 | 3 | 4 | public record ContextParameter(string Name, string Type, object Value); -------------------------------------------------------------------------------- /TaskTower/Domain/EnqueuedJobs/EnqueuedJob.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.EnqueuedJobs; 2 | 3 | using TaskTowerJob; 4 | 5 | public class EnqueuedJob 6 | { 7 | public Guid Id { get; private set; } 8 | 9 | /// 10 | /// The queue to enqueue the job to 11 | /// 12 | public string Queue { get; private set; } = null!; 13 | 14 | /// 15 | /// The id of the job 16 | /// 17 | public Guid JobId { get; private set; } 18 | internal TaskTowerJob Job { get; } = null!; 19 | 20 | public static EnqueuedJob Create(string queue, Guid jobId) 21 | { 22 | var enqueuedJob = new EnqueuedJob(); 23 | enqueuedJob.Queue = queue; 24 | enqueuedJob.JobId = jobId; 25 | return enqueuedJob; 26 | } 27 | 28 | private EnqueuedJob() { } // EF Core 29 | } -------------------------------------------------------------------------------- /TaskTower/Domain/InterceptionStages/InterceptionStage.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.InterceptionStages; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | using Ardalis.SmartEnum; 5 | 6 | public class InterceptionStage : ValueObject 7 | { 8 | private InterceptionStageEnum _status; 9 | public string Value 10 | { 11 | get => _status.Name; 12 | private set 13 | { 14 | if (!InterceptionStageEnum.TryFromName(value, true, out var parsed)) 15 | throw new ValidationException($"Invalid Interception Stage. Please use one of the following: {string.Join("", "", ListNames())}"); 16 | 17 | _status = parsed; 18 | } 19 | } 20 | 21 | public InterceptionStage(string value) 22 | { 23 | Value = value; 24 | } 25 | 26 | public bool IsPreProcessing() => Value == PreProcessing().Value; 27 | public bool IsSuccess() => Value == Success().Value; 28 | public bool IsFailure() => Value == Failure().Value; 29 | public bool IsDeath() => Value == Death().Value; 30 | public static InterceptionStage Of(string value) => new InterceptionStage(value); 31 | public static implicit operator string(InterceptionStage value) => value.Value; 32 | public static List ListNames() => InterceptionStageEnum.List.Select(x => x.Name).ToList(); 33 | public static InterceptionStage PreProcessing() => new InterceptionStage(InterceptionStageEnum.PreProcessing.Name); 34 | public static InterceptionStage Success() => new InterceptionStage(InterceptionStageEnum.Success.Name); 35 | public static InterceptionStage Failure() => new InterceptionStage(InterceptionStageEnum.Failure.Name); 36 | public static InterceptionStage Death() => new InterceptionStage(InterceptionStageEnum.Death.Name); 37 | 38 | protected InterceptionStage() { } // EF Core 39 | 40 | private abstract class InterceptionStageEnum : SmartEnum 41 | { 42 | public static readonly InterceptionStageEnum PreProcessing = new PreProcessingType(); 43 | public static readonly InterceptionStageEnum Success = new SuccessType(); 44 | public static readonly InterceptionStageEnum Failure = new FailureType(); 45 | public static readonly InterceptionStageEnum Death = new DeathType(); 46 | 47 | protected InterceptionStageEnum(string name, int value) : base(name, value) 48 | { 49 | } 50 | 51 | private class PreProcessingType : InterceptionStageEnum 52 | { 53 | public PreProcessingType() : base("PreProcessing", 0) 54 | { 55 | } 56 | } 57 | 58 | private class SuccessType : InterceptionStageEnum 59 | { 60 | public SuccessType() : base("Success", 1) 61 | { 62 | } 63 | } 64 | 65 | private class FailureType : InterceptionStageEnum 66 | { 67 | public FailureType() : base("Failure", 2) 68 | { 69 | } 70 | } 71 | 72 | private class DeathType : InterceptionStageEnum 73 | { 74 | public DeathType() : base("Death", 3) 75 | { 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /TaskTower/Domain/JobStatuses/JobStatus.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.JobStatuses; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | using Ardalis.SmartEnum; 5 | 6 | public class JobStatus : ValueObject 7 | { 8 | private JobStatusEnum _status; 9 | public string Value 10 | { 11 | get => _status.Name; 12 | private set 13 | { 14 | if (!JobStatusEnum.TryFromName(value, true, out var parsed)) 15 | throw new ValidationException($"Invalid Status. Please use one of the following: {string.Join("", "", ListNames())}"); 16 | 17 | _status = parsed; 18 | } 19 | } 20 | 21 | public JobStatus(string value) 22 | { 23 | Value = value; 24 | } 25 | 26 | public bool IsPending() => Value == Pending().Value; 27 | public bool IsEnqueued() => Value == Enqueued().Value; 28 | public bool IsProcessing() => Value == Processing().Value; 29 | public bool IsCompleted() => Value == Completed().Value; 30 | public bool IsFailed() => Value == Failed().Value; 31 | public bool IsDead() => Value == Dead().Value; 32 | public static JobStatus Of(string value) => new JobStatus(value); 33 | public static implicit operator string(JobStatus value) => value.Value; 34 | public static List ListNames() => JobStatusEnum.List.Select(x => x.Name).ToList(); 35 | public static JobStatus Pending() => new JobStatus(JobStatusEnum.Pending.Name); 36 | public static JobStatus Enqueued() => new JobStatus(JobStatusEnum.Enqueued.Name); 37 | public static JobStatus Processing() => new JobStatus(JobStatusEnum.Processing.Name); 38 | public static JobStatus Completed() => new JobStatus(JobStatusEnum.Completed.Name); 39 | public static JobStatus Failed() => new JobStatus(JobStatusEnum.Failed.Name); 40 | public static JobStatus Dead() => new JobStatus(JobStatusEnum.Dead.Name); 41 | 42 | protected JobStatus() { } // EF Core 43 | 44 | private abstract class JobStatusEnum : SmartEnum 45 | { 46 | public static readonly JobStatusEnum Pending = new PendingType(); 47 | public static readonly JobStatusEnum Enqueued = new EnqueuedType(); 48 | public static readonly JobStatusEnum Processing = new ProcessingType(); 49 | public static readonly JobStatusEnum Completed = new CompletedType(); 50 | public static readonly JobStatusEnum Failed = new FailedType(); 51 | public static readonly JobStatusEnum Dead = new DeadType(); 52 | 53 | protected JobStatusEnum(string name, int value) : base(name, value) 54 | { 55 | } 56 | 57 | private class PendingType : JobStatusEnum 58 | { 59 | public PendingType() : base("Pending", 0) 60 | { 61 | } 62 | } 63 | 64 | private class EnqueuedType : JobStatusEnum 65 | { 66 | public EnqueuedType() : base("Enqueued", 1) 67 | { 68 | } 69 | } 70 | 71 | private class ProcessingType : JobStatusEnum 72 | { 73 | public ProcessingType() : base("Processing", 2) 74 | { 75 | } 76 | } 77 | 78 | private class CompletedType : JobStatusEnum 79 | { 80 | public CompletedType() : base("Completed", 3) 81 | { 82 | } 83 | } 84 | 85 | private class FailedType : JobStatusEnum 86 | { 87 | public FailedType() : base("Failed", 4) 88 | { 89 | } 90 | } 91 | 92 | private class DeadType : JobStatusEnum 93 | { 94 | public DeadType() : base("Dead", 5) 95 | { 96 | } 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /TaskTower/Domain/JobStatuses/Mappings/JobStatusTypeHandler.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.JobStatuses.Mappings; 2 | 3 | using System.Data; 4 | using Dapper; 5 | 6 | public class JobStatusTypeHandler : SqlMapper.ITypeHandler 7 | { 8 | public void SetValue(IDbDataParameter parameter, object value) 9 | { 10 | parameter.Value = value.ToString(); 11 | } 12 | 13 | public object Parse(Type destinationType, object value) 14 | { 15 | return JobStatus.Of(value.ToString()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /TaskTower/Domain/JobStatuses/RunHistoryStatus.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.RunHistoryStatuses; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | using Ardalis.SmartEnum; 5 | 6 | public class RunHistoryStatus : ValueObject 7 | { 8 | private RunHistoryStatusEnum _status; 9 | public string Value 10 | { 11 | get => _status.Name; 12 | private set 13 | { 14 | if (!RunHistoryStatusEnum.TryFromName(value, true, out var parsed)) 15 | throw new ValidationException($"Invalid Status. Please use one of the following: {string.Join("", "", ListNames())}"); 16 | 17 | _status = parsed; 18 | } 19 | } 20 | 21 | public RunHistoryStatus(string value) 22 | { 23 | Value = value; 24 | } 25 | 26 | public bool IsPending() => Value == Pending().Value; 27 | public bool IsEnqueued() => Value == Enqueued().Value; 28 | public bool IsProcessing() => Value == Processing().Value; 29 | public bool IsCompleted() => Value == Completed().Value; 30 | public bool IsFailed() => Value == Failed().Value; 31 | public bool IsDead() => Value == Dead().Value; 32 | public bool IsRequeued() => Value == Requeued().Value; 33 | public static RunHistoryStatus Of(string value) => new RunHistoryStatus(value); 34 | public static implicit operator string(RunHistoryStatus value) => value.Value; 35 | public static List ListNames() => RunHistoryStatusEnum.List.Select(x => x.Name).ToList(); 36 | public static RunHistoryStatus Pending() => new RunHistoryStatus(RunHistoryStatusEnum.Pending.Name); 37 | public static RunHistoryStatus Enqueued() => new RunHistoryStatus(RunHistoryStatusEnum.Enqueued.Name); 38 | public static RunHistoryStatus Processing() => new RunHistoryStatus(RunHistoryStatusEnum.Processing.Name); 39 | public static RunHistoryStatus Completed() => new RunHistoryStatus(RunHistoryStatusEnum.Completed.Name); 40 | public static RunHistoryStatus Failed() => new RunHistoryStatus(RunHistoryStatusEnum.Failed.Name); 41 | public static RunHistoryStatus Dead() => new RunHistoryStatus(RunHistoryStatusEnum.Dead.Name); 42 | public static RunHistoryStatus Requeued() => new RunHistoryStatus(RunHistoryStatusEnum.Requeued.Name); 43 | 44 | protected RunHistoryStatus() { } // EF Core 45 | 46 | private abstract class RunHistoryStatusEnum : SmartEnum 47 | { 48 | public static readonly RunHistoryStatusEnum Pending = new PendingType(); 49 | public static readonly RunHistoryStatusEnum Enqueued = new EnqueuedType(); 50 | public static readonly RunHistoryStatusEnum Processing = new ProcessingType(); 51 | public static readonly RunHistoryStatusEnum Completed = new CompletedType(); 52 | public static readonly RunHistoryStatusEnum Failed = new FailedType(); 53 | public static readonly RunHistoryStatusEnum Dead = new DeadType(); 54 | public static readonly RunHistoryStatusEnum Requeued = new RequeuedType(); 55 | 56 | protected RunHistoryStatusEnum(string name, int value) : base(name, value) 57 | { 58 | } 59 | 60 | private class PendingType : RunHistoryStatusEnum 61 | { 62 | public PendingType() : base("Pending", 0) 63 | { 64 | } 65 | } 66 | 67 | private class EnqueuedType : RunHistoryStatusEnum 68 | { 69 | public EnqueuedType() : base("Enqueued", 1) 70 | { 71 | } 72 | } 73 | 74 | private class ProcessingType : RunHistoryStatusEnum 75 | { 76 | public ProcessingType() : base("Processing", 2) 77 | { 78 | } 79 | } 80 | 81 | private class CompletedType : RunHistoryStatusEnum 82 | { 83 | public CompletedType() : base("Completed", 3) 84 | { 85 | } 86 | } 87 | 88 | private class FailedType : RunHistoryStatusEnum 89 | { 90 | public FailedType() : base("Failed", 4) 91 | { 92 | } 93 | } 94 | 95 | private class DeadType : RunHistoryStatusEnum 96 | { 97 | public DeadType() : base("Dead", 5) 98 | { 99 | } 100 | } 101 | 102 | private class RequeuedType : RunHistoryStatusEnum 103 | { 104 | public RequeuedType() : base("Requeued", 6) 105 | { 106 | } 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /TaskTower/Domain/RunHistories/Models/JobRunHistoryView.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.RunHistories.Models; 2 | 3 | using JobStatuses; 4 | 5 | public record JobRunHistoryView() 6 | { 7 | public Guid Id { get; set; } 8 | public Guid JobId { get; set; } 9 | public string Status { get; set; } = null!; 10 | public string? Comment { get; set; } 11 | public string? Details { get; set; } 12 | public DateTimeOffset? OccurredAt { get; set; } 13 | } -------------------------------------------------------------------------------- /TaskTower/Domain/RunHistories/Models/RunHistoryForCreation.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.RunHistories.Models; 2 | 3 | using JobStatuses; 4 | using RunHistoryStatuses; 5 | 6 | public record RunHistoryForCreation() 7 | { 8 | public Guid JobId { get; set; } 9 | public RunHistoryStatus Status { get; set; } 10 | public string? Comment { get; set; } 11 | public string? Details { get; set; } 12 | public DateTimeOffset? OccurredAt { get; set; } 13 | } -------------------------------------------------------------------------------- /TaskTower/Domain/RunHistories/RunHistory.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.RunHistories; 2 | 3 | using JobStatuses; 4 | using Models; 5 | using RunHistoryStatuses; 6 | using TaskTowerJob; 7 | 8 | public class RunHistory 9 | { 10 | public Guid Id { get; private set; } 11 | public Guid JobId { get; private set; } 12 | internal TaskTowerJob Job { get; } = null!; 13 | public RunHistoryStatus Status { get; private set; } = default!; 14 | public string? Comment { get; private set; } 15 | public string? Details { get; private set; } 16 | public DateTimeOffset OccurredAt { get; private set; } 17 | 18 | public static RunHistory Create(RunHistoryForCreation runHistoryForCreation) 19 | { 20 | var runHistory = new RunHistory(); 21 | 22 | runHistory.Id = Guid.NewGuid(); 23 | runHistory.JobId = runHistoryForCreation.JobId; 24 | runHistory.Status = runHistoryForCreation.Status; 25 | runHistory.Comment = runHistoryForCreation.Comment; 26 | runHistory.Details = runHistoryForCreation.Details; 27 | runHistory.OccurredAt = runHistoryForCreation.OccurredAt ?? DateTimeOffset.UtcNow; 28 | return runHistory; 29 | } 30 | 31 | public static RunHistory Create(Guid jobId, RunHistoryStatus status) 32 | { 33 | var historyForCreation = new RunHistoryForCreation() 34 | { 35 | JobId = jobId, 36 | Status = status 37 | }; 38 | return Create(historyForCreation); 39 | } 40 | } -------------------------------------------------------------------------------- /TaskTower/Domain/RunHistories/Services/JobRunHistoryRepository.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.RunHistories.Services; 2 | 3 | using System.Text; 4 | using Configurations; 5 | using Dapper; 6 | using Database; 7 | using Microsoft.Extensions.Options; 8 | using Models; 9 | using Npgsql; 10 | using Resources; 11 | using TaskTowerJob.Dtos; 12 | 13 | internal interface IJobRunHistoryRepository 14 | { 15 | Task> GetJobRunHistoryViews(Guid jobId, CancellationToken cancellationToken = default); 16 | } 17 | 18 | internal class JobRunHistoryRepository(IOptions options) : IJobRunHistoryRepository 19 | { 20 | public async Task> GetJobRunHistoryViews(Guid jobId, CancellationToken cancellationToken = default) 21 | { 22 | await using var conn = new NpgsqlConnection(options.Value.ConnectionString); 23 | await conn.OpenAsync(cancellationToken); 24 | var runHistoryView = await conn.QueryAsync( 25 | @$" 26 | SELECT 27 | rh.id as Id, 28 | rh.job_id as JobId, 29 | rh.status as Status, 30 | rh.comment as Comment, 31 | rh.details as Details, 32 | rh.occurred_at as OccurredAt 33 | FROM {MigrationConfig.SchemaName}.run_histories rh 34 | WHERE rh.job_id = @JobId 35 | order by rh.occurred_at desc, 36 | CASE 37 | WHEN rh.status = 'Pending' THEN 1 38 | WHEN rh.status = 'Enqueued' THEN 2 39 | WHEN rh.status = 'Processing' THEN 3 40 | ELSE 4 41 | END DESC 42 | ", 43 | new {JobId = jobId}); 44 | 45 | return runHistoryView.ToList(); 46 | } 47 | 48 | internal static async Task AddRunHistory(NpgsqlConnection conn, RunHistory runHistory, NpgsqlTransaction tx) 49 | { 50 | await conn.ExecuteAsync( 51 | $"INSERT INTO {MigrationConfig.SchemaName}.run_histories(id, job_id, status, comment, details, occurred_at) VALUES (@Id, @JobId, @Status, @Comment, @Details, @OccurredAt)", 52 | new { runHistory.Id, runHistory.JobId, Status = runHistory.Status.Value, runHistory.Comment, runHistory.Details, runHistory.OccurredAt }, 53 | transaction: tx 54 | ); 55 | } 56 | } -------------------------------------------------------------------------------- /TaskTower/Domain/TaskTowerJob/Dtos/JobParametersDto.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.TaskTowerJob.Dtos; 2 | 3 | using Resources; 4 | 5 | public class JobParametersDto : BasePaginationParameters 6 | { 7 | public string[] StatusFilter { get; set; } = Array.Empty(); 8 | public string[] QueueFilter { get; set; } = Array.Empty(); 9 | public string? FilterText { get; set; } 10 | } -------------------------------------------------------------------------------- /TaskTower/Domain/TaskTowerJob/Dtos/TaskTowerJobView.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.TaskTowerJob.Dtos; 2 | 3 | using RunHistories.Models; 4 | 5 | public sealed record TaskTowerJobView 6 | { 7 | public TaskTowerJobWithTagsView Job { get; set; } = null!; 8 | public List History { get; set; } = new(); 9 | } -------------------------------------------------------------------------------- /TaskTower/Domain/TaskTowerJob/Dtos/TaskTowerJobWithTagsView.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.TaskTowerJob.Dtos; 2 | 3 | public sealed record TaskTowerJobWithTagsView 4 | { 5 | 6 | public Guid Id { get; set; } 7 | 8 | public string? Queue { get; set; } 9 | 10 | public string Status { get; set; } 11 | 12 | public string Type { get; set; } = null!; 13 | 14 | public string Method { get; set; } = null!; 15 | 16 | public string[] ParameterTypes { get; set; } = Array.Empty(); 17 | 18 | public string Payload { get; set; } = null!; 19 | 20 | public int Retries { get; set; } = 0; 21 | 22 | public int? MaxRetries { get; set; } 23 | 24 | public DateTimeOffset RunAfter { get; set; } 25 | 26 | public DateTimeOffset? RanAt { get; set; } 27 | 28 | public DateTimeOffset CreatedAt { get; set; } 29 | 30 | public DateTimeOffset? Deadline { get; set; } 31 | 32 | public string JobName { get; set; } 33 | public string? TagNames { get; set; } 34 | public string[] Tags => TagNames == null 35 | ? Array.Empty() 36 | : 37 | TagNames.Contains(',') 38 | ? TagNames.Split(",") 39 | : new[] { TagNames.Trim() }; 40 | } -------------------------------------------------------------------------------- /TaskTower/Domain/TaskTowerJob/Features/GetJobView.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.TaskTowerJob.Features; 2 | 3 | using Dtos; 4 | using RunHistories.Services; 5 | using Services; 6 | 7 | public interface IJobViewer 8 | { 9 | Task GetJobView(Guid jobId, CancellationToken cancellationToken = default); 10 | } 11 | 12 | internal class JobViewer(ITaskTowerJobRepository taskTowerJobRepository, 13 | IJobRunHistoryRepository jobRunHistoryRepository) : IJobViewer 14 | { 15 | public async Task GetJobView(Guid jobId, CancellationToken cancellationToken = default) 16 | { 17 | var viewData = new TaskTowerJobView(); 18 | var jobViewBase = await taskTowerJobRepository.GetJobView(jobId, cancellationToken); 19 | if (jobViewBase == null) 20 | { 21 | throw new KeyNotFoundException("Job not found"); 22 | } 23 | viewData.Job = jobViewBase; 24 | 25 | var history = await jobRunHistoryRepository.GetJobRunHistoryViews(jobId, cancellationToken); 26 | viewData.History = history; 27 | 28 | return viewData; 29 | } 30 | } -------------------------------------------------------------------------------- /TaskTower/Domain/TaskTowerJob/Models/TaskTowerJobForCreation.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.TaskTowerJob.Models; 2 | 3 | public record TaskTowerJobForCreation() 4 | { 5 | public string? Queue { get; set; } = null!; 6 | public string Type { get; set; } = null!; 7 | public string Method { get; set; } = null!; 8 | public string? JobName { get; set; } 9 | public string[]? ParameterTypes { get; set; } 10 | public string Payload { get; set; } = null!; 11 | public int? MaxRetries { get; set; } 12 | public DateTimeOffset? RunAfter { get; set; } 13 | public DateTimeOffset? Deadline { get; set; } 14 | } -------------------------------------------------------------------------------- /TaskTower/Domain/TaskTowerTags/TaskTowerTag.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain.TaskTowerTags; 2 | 3 | using TaskTowerJob; 4 | 5 | public class TaskTowerTag 6 | { 7 | /// 8 | /// The id of the job 9 | /// 10 | public Guid JobId { get; private set; } 11 | internal TaskTowerJob Job { get; } = null!; 12 | 13 | /// 14 | /// The name of the tag 15 | /// 16 | public string Name { get; private set; } = null!; 17 | 18 | public static TaskTowerTag Create(Guid jobId, string name) 19 | { 20 | var tag = new TaskTowerTag(); 21 | 22 | if (jobId == Guid.Empty) 23 | { 24 | throw new ArgumentException("JobId cannot be empty", nameof(jobId)); 25 | } 26 | if (string.IsNullOrWhiteSpace(name)) 27 | { 28 | throw new ArgumentException("Tag name cannot be empty", nameof(name)); 29 | } 30 | 31 | tag.JobId = jobId; 32 | tag.Name = name; 33 | return tag; 34 | } 35 | 36 | private TaskTowerTag() { } // EF Core 37 | } -------------------------------------------------------------------------------- /TaskTower/Domain/ValueObject.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Domain; 2 | 3 | using System.Reflection; 4 | 5 | // source: https://github.com/jhewlett/ValueObject 6 | public abstract class ValueObject : IEquatable 7 | { 8 | private List? _properties; 9 | private List? _fields; 10 | 11 | public static bool operator ==(ValueObject? obj1, ValueObject? obj2) 12 | { 13 | if (Equals(obj1, null)) 14 | { 15 | if (Equals(obj2, null)) 16 | return true; 17 | 18 | return false; 19 | } 20 | 21 | return obj1.Equals(obj2); 22 | } 23 | 24 | public static bool operator !=(ValueObject? obj1, ValueObject? obj2) 25 | { 26 | return !(obj1 == obj2); 27 | } 28 | 29 | public bool Equals(ValueObject? obj) 30 | { 31 | return Equals(obj as object); 32 | } 33 | 34 | public override bool Equals(object? obj) 35 | { 36 | if (obj == null || GetType() != obj.GetType()) 37 | return false; 38 | return GetProperties().All(p => PropertiesAreEqual(obj, p)) && GetFields().All(f => FieldsAreEqual(obj, f)); 39 | } 40 | 41 | private bool PropertiesAreEqual(object obj, PropertyInfo p) 42 | { 43 | return Equals(p.GetValue(this, null), p.GetValue(obj, null)); 44 | } 45 | 46 | private bool FieldsAreEqual(object obj, FieldInfo f) 47 | { 48 | return Equals(f.GetValue(this), f.GetValue(obj)); 49 | } 50 | 51 | private IEnumerable GetProperties() 52 | { 53 | return _properties ??= GetType() 54 | .GetProperties(BindingFlags.Instance | BindingFlags.Public) 55 | .Where(p => p.GetCustomAttribute(typeof(IgnoreMemberAttribute)) == null) 56 | .ToList(); 57 | } 58 | 59 | private IEnumerable GetFields() 60 | { 61 | return _fields ??= GetType().GetFields(BindingFlags.Instance | BindingFlags.Public) 62 | .Where(p => p.GetCustomAttribute(typeof(IgnoreMemberAttribute)) == null) 63 | .ToList(); 64 | } 65 | 66 | public override int GetHashCode() 67 | { 68 | unchecked //allow overflow 69 | { 70 | int hash = 17; 71 | foreach (var prop in GetProperties()) 72 | { 73 | var value = prop.GetValue(this, null); 74 | hash = HashValue(hash, value); 75 | } 76 | 77 | foreach (var field in GetFields()) 78 | { 79 | var value = field.GetValue(this); 80 | hash = HashValue(hash, value); 81 | } 82 | 83 | return hash; 84 | } 85 | } 86 | 87 | private int HashValue(int seed, object? value) 88 | { 89 | var currentHash = value != null ? value.GetHashCode() : 0; 90 | return seed * 23 + currentHash; 91 | } 92 | } 93 | 94 | [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] 95 | public class IgnoreMemberAttribute : Attribute 96 | { 97 | } -------------------------------------------------------------------------------- /TaskTower/Exceptions/InvalidTaskTowerTagException.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Exceptions; 2 | 3 | public class InvalidTaskTowerTagException : TaskTowerException 4 | { 5 | public InvalidTaskTowerTagException(string message, Exception innerException) : base(message, innerException) 6 | { 7 | } 8 | 9 | public InvalidTaskTowerTagException() : base("Invalid Task Tower tag name") 10 | { 11 | } 12 | 13 | public InvalidTaskTowerTagException(string tagName) : base($"Invalid Task Tower tag name: '{tagName}'") 14 | { 15 | } 16 | } -------------------------------------------------------------------------------- /TaskTower/Exceptions/MissingTaskTowerOptionsException.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Exceptions; 2 | 3 | public class MissingTaskTowerOptionsException : TaskTowerException 4 | { 5 | public MissingTaskTowerOptionsException(string message) : base(message) 6 | { 7 | } 8 | 9 | public MissingTaskTowerOptionsException(string message, Exception innerException) : base(message, innerException) 10 | { 11 | } 12 | 13 | public MissingTaskTowerOptionsException() : base("No TaskTowerOptions were found in the service provider") 14 | { 15 | } 16 | } -------------------------------------------------------------------------------- /TaskTower/Exceptions/TaskTowerException.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Exceptions; 2 | 3 | public class TaskTowerException : Exception 4 | { 5 | public TaskTowerException(string message) : base(message) 6 | { 7 | } 8 | 9 | public TaskTowerException(string message, Exception innerException) : base(message, innerException) 10 | { 11 | } 12 | } -------------------------------------------------------------------------------- /TaskTower/Exceptions/TaskTowerJobNotFoundException.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Exceptions; 2 | 3 | public class TaskTowerJobNotFoundException : TaskTowerException 4 | { 5 | public TaskTowerJobNotFoundException(string message, Exception innerException) : base(message, innerException) 6 | { 7 | } 8 | 9 | public TaskTowerJobNotFoundException() : base("Task Tower job not found") 10 | { 11 | } 12 | } -------------------------------------------------------------------------------- /TaskTower/Interception/InterceptionContextErrorDetails.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Interception; 2 | 3 | 4 | public record ErrorDetails(string? Message, string? Details, DateTimeOffset? OccurredAt); -------------------------------------------------------------------------------- /TaskTower/Interception/JobInterceptor.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Interception; 2 | 3 | using Domain.InterceptionStages; 4 | 5 | public class JobInterceptor 6 | { 7 | internal InterceptionStage InterceptionStage { get; private set; } 8 | 9 | private readonly IServiceProvider _serviceProvider; 10 | 11 | public JobInterceptor(IServiceProvider serviceProvider) 12 | { 13 | _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); 14 | } 15 | 16 | public virtual JobServiceProvider Intercept(JobInterceptorContext interceptorContext) 17 | { 18 | return new JobServiceProvider(_serviceProvider); 19 | } 20 | 21 | public virtual JobServiceProvider Intercept() 22 | { 23 | return new JobServiceProvider(_serviceProvider); 24 | } 25 | 26 | internal void SetInterceptionStage(InterceptionStage interceptionStage) 27 | { 28 | InterceptionStage = interceptionStage; 29 | } 30 | } -------------------------------------------------------------------------------- /TaskTower/Interception/JobInterceptorContext.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Interception; 2 | 3 | using System.Text.Json; 4 | using TaskTower.Domain; 5 | using TaskTower.Domain.TaskTowerJob; 6 | 7 | /// 8 | /// Provides context from a job during interceptor execution 9 | /// 10 | public class JobInterceptorContext 11 | { 12 | private readonly List _contextParameters = new(); 13 | public IReadOnlyList ContextParameters => _contextParameters; 14 | public TaskTowerJob Job { get; private set; } 15 | public ErrorDetails? ErrorDetails { get; private set; } 16 | 17 | 18 | public T? GetContextParameter(string name) 19 | { 20 | if (string.IsNullOrWhiteSpace(name)) 21 | throw new ArgumentNullException(nameof (name)); 22 | 23 | var contextParameter = _contextParameters.FirstOrDefault(x => x.Name == name); 24 | 25 | if (contextParameter == null) 26 | throw new ArgumentException($"Context parameter with name {name} not found"); 27 | 28 | var contextParameterType = Type.GetType(contextParameter.Type); 29 | 30 | if (contextParameterType != typeof(T)) 31 | throw new ArgumentException($"Context parameter with name {name} does not match stored type {typeof(T).Name}"); 32 | 33 | if (contextParameterType == null) 34 | throw new ArgumentException($"Context parameter with name {name} has a null type"); 35 | 36 | try 37 | { 38 | // Assuming contextParameter.Value is either a string containing JSON or a JsonElement 39 | var jsonValue = contextParameter.Value is JsonElement jsonElement 40 | ? jsonElement.GetRawText() 41 | : contextParameter?.Value?.ToString(); 42 | // 43 | // // if (Nullable.GetUnderlyingType(contextParameterType) != null) 44 | // // return null; // It's nullable, return null 45 | // 46 | // if (jsonValue == null) 47 | // jsonValue = "{}"; 48 | 49 | if (jsonValue == null) 50 | // TODO need to add null handling 51 | throw new ArgumentException($"Context parameter with name {name} has a null value"); 52 | 53 | return JsonSerializer.Deserialize(jsonValue); 54 | } 55 | catch (JsonException) 56 | { 57 | throw new ArgumentException($"Context parameter with name {name} could not be deserialized to type {typeof(T).Name}"); 58 | } 59 | } 60 | 61 | internal static JobInterceptorContext Create(TaskTowerJob job) 62 | { 63 | var context = new JobInterceptorContext(); 64 | context._contextParameters.AddRange(job.ContextParameters); 65 | context.Job = job; 66 | return context; 67 | } 68 | 69 | internal void SetErrorDetails(ErrorDetails errorDetails) 70 | { 71 | ErrorDetails = errorDetails; 72 | } 73 | } -------------------------------------------------------------------------------- /TaskTower/Interception/JobServiceProvider.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Interception; 2 | 3 | public class JobServiceProvider 4 | { 5 | private readonly IServiceProvider _serviceProvider; 6 | 7 | public JobServiceProvider(IServiceProvider serviceProvider) 8 | { 9 | _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); 10 | } 11 | 12 | public IServiceProvider GetServiceProvider() 13 | { 14 | return _serviceProvider; 15 | } 16 | } -------------------------------------------------------------------------------- /TaskTower/Interception/TaskTowerRunnerContext.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Interception; 2 | 3 | 4 | /// 5 | /// A store for the current running job information for accessing the current job id 6 | /// 7 | public interface ITaskTowerRunnerContext 8 | { 9 | /// 10 | /// The id of the current running job 11 | /// 12 | public Guid JobId { get; set; } 13 | } 14 | 15 | /// 16 | public sealed class TaskTowerRunnerContext : ITaskTowerRunnerContext 17 | { 18 | /// 19 | public Guid JobId { get; set; } 20 | } -------------------------------------------------------------------------------- /TaskTower/Interception/TaskTowerRunnerContextInterceptor.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Interception; 2 | 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | internal class TaskTowerRunnerContextInterceptor : JobInterceptor 6 | { 7 | private readonly IServiceProvider _serviceProvider; 8 | 9 | public TaskTowerRunnerContextInterceptor(IServiceProvider serviceProvider) : base(serviceProvider) 10 | { 11 | _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); 12 | } 13 | 14 | public override JobServiceProvider Intercept(JobInterceptorContext interceptorContext) 15 | { 16 | var taskTowerRunnerContext = _serviceProvider.GetRequiredService(); 17 | taskTowerRunnerContext.JobId = interceptorContext.Job.Id; 18 | 19 | return new JobServiceProvider(_serviceProvider); 20 | } 21 | } -------------------------------------------------------------------------------- /TaskTower/Middleware/JobContext.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Middleware; 2 | 3 | using TaskTower.Domain.TaskTowerJob; 4 | 5 | public class JobContext 6 | { 7 | public TaskTowerJob Job { get; } 8 | public void SetJobContextParameter(string name, object value) 9 | { 10 | if (string.IsNullOrWhiteSpace(name)) 11 | throw new ArgumentNullException(nameof (name)); 12 | Job.SetContextParameter(name, value); 13 | } 14 | 15 | internal JobContext(TaskTowerJob job) 16 | { 17 | Job = job; 18 | } 19 | } 20 | 21 | /// 22 | /// Allows you to add context when creating your job that can be used later in the job's lifecycle (e.g. interceptors). 23 | /// 24 | public interface IJobContextualizer 25 | { 26 | public void EnrichContext(JobContext context); 27 | } -------------------------------------------------------------------------------- /TaskTower/Processing/ScheduleBuilder.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Processing; 2 | 3 | using System.Linq.Expressions; 4 | 5 | public interface IScheduleBuilder 6 | { 7 | IScheduleBuilder ToQueue(string queue); 8 | Task InMilliseconds(int milliseconds); 9 | Task InSeconds(int seconds); 10 | Task InMinutes(int minutes); 11 | Task InHours(int hours); 12 | Task InDays(int days); 13 | Task InWeeks(int weeks); 14 | Task InMonths(int months); 15 | Task InYears(int years); 16 | Task AtSpecificTime(DateTimeOffset time); 17 | Task Immediately(); 18 | } 19 | 20 | public class ScheduleBuilder : IScheduleBuilder 21 | { 22 | private readonly IBackgroundJobClient _client; 23 | private readonly Expression _methodCall; 24 | private readonly string? _queue; 25 | private readonly CancellationToken _cancellationToken; 26 | 27 | public ScheduleBuilder(IBackgroundJobClient client, Expression methodCall, string? queue, CancellationToken cancellationToken = default) 28 | { 29 | _client = client; 30 | _methodCall = methodCall; 31 | _queue = queue; 32 | _cancellationToken = cancellationToken; 33 | } 34 | 35 | public ScheduleBuilder(IBackgroundJobClient client, Expression methodCall, CancellationToken cancellationToken = default) 36 | { 37 | _client = client; 38 | _methodCall = methodCall; 39 | _queue = null; 40 | _cancellationToken = cancellationToken; 41 | } 42 | 43 | public IScheduleBuilder ToQueue(string queue) => new ScheduleBuilder(_client, _methodCall, queue, _cancellationToken); 44 | public Task InMilliseconds(int milliseconds) => Schedule(TimeSpan.FromMilliseconds(milliseconds)); 45 | public Task InSeconds(int seconds) => Schedule(TimeSpan.FromSeconds(seconds)); 46 | public Task InMinutes(int minutes) => Schedule(TimeSpan.FromMinutes(minutes)); 47 | public Task InHours(int hours) => Schedule(TimeSpan.FromHours(hours)); 48 | public Task InDays(int days) => Schedule(TimeSpan.FromDays(days)); 49 | public Task InWeeks(int weeks) => Schedule(TimeSpan.FromDays(7 * weeks)); 50 | 51 | public Task InMonths(int months) 52 | => AtSpecificTime(DateTimeOffset.UtcNow.AddMonths(months)); 53 | 54 | public Task InYears(int years) 55 | => AtSpecificTime(DateTimeOffset.UtcNow.AddYears(years)); 56 | 57 | public Task Immediately() => Schedule(TimeSpan.Zero); 58 | public Task AtSpecificTime(DateTimeOffset dateTime) 59 | { 60 | var now = DateTimeOffset.UtcNow; 61 | var delay = dateTime > now ? dateTime - now : TimeSpan.Zero; 62 | return Schedule(delay); 63 | } 64 | 65 | private Task Schedule(TimeSpan delay) 66 | { 67 | switch (_methodCall) 68 | { 69 | case Expression action: 70 | return _client.Schedule(action, delay, _queue, _cancellationToken); 71 | case Expression> actionT: 72 | return _client.Schedule(actionT, delay, _queue, _cancellationToken); 73 | case Expression> funcT: 74 | return _client.Schedule(funcT, delay, _queue, _cancellationToken); 75 | default: 76 | throw new InvalidOperationException("Unsupported expression type."); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@nextui-org/* -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | Task Tower 12 | 13 | 14 |
15 | 16 | 17 | 20 | 21 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "task-tower-ui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@heroicons/react": "^2.1.1", 14 | "@nextui-org/react": "^2.2.9", 15 | "@radix-ui/react-slot": "^1.0.2", 16 | "@tanstack/eslint-plugin-query": "^5.20.1", 17 | "@tanstack/react-query": "^5.24.1", 18 | "@tanstack/react-query-devtools": "^5.24.1", 19 | "@tanstack/react-router": "^1.18.2", 20 | "@tanstack/react-table": "^8.13.2", 21 | "@tanstack/router-devtools": "^1.18.2", 22 | "axios": "^1.6.7", 23 | "class-variance-authority": "^0.7.0", 24 | "clsx": "^2.1.0", 25 | "cmdk": "^0.2.1", 26 | "date-fns": "^3.3.1", 27 | "framer-motion": "^11.0.8", 28 | "lucide-react": "^0.344.0", 29 | "query-string": "^9.0.0", 30 | "react": "^18.2.0", 31 | "react-dom": "^18.2.0", 32 | "react-helmet": "^6.1.0", 33 | "react-hot-toast": "^2.4.1", 34 | "react-syntax-highlighter": "^15.5.0", 35 | "tailwind-merge": "^2.2.1", 36 | "vite-plugin-static-copy": "^1.0.1", 37 | "zod": "^3.22.4", 38 | "zustand": "^4.5.2" 39 | }, 40 | "devDependencies": { 41 | "@types/react": "^18.2.56", 42 | "@types/react-dom": "^18.2.19", 43 | "@types/react-helmet": "^6.1.11", 44 | "@types/react-syntax-highlighter": "^15.5.11", 45 | "@typescript-eslint/eslint-plugin": "^7.0.2", 46 | "@typescript-eslint/parser": "^7.0.2", 47 | "@vitejs/plugin-react-swc": "^3.5.0", 48 | "autoprefixer": "^10.4.17", 49 | "eslint": "^8.56.0", 50 | "eslint-plugin-react-hooks": "^4.6.0", 51 | "eslint-plugin-react-refresh": "^0.4.5", 52 | "postcss": "^8.4.35", 53 | "tailwindcss": "^3.4.1", 54 | "tailwindcss-debug-screens": "^2.2.1", 55 | "typescript": "^5.2.2", 56 | "vite": "^5.1.4" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/badge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "../utils"; 2 | 3 | export type BadgeVariant = 4 | | "amber" 5 | | "red" 6 | | "gray" 7 | | "yellow" 8 | | "blue" 9 | | "indigo" 10 | | "sky" 11 | | "rose" 12 | | "pink" 13 | | "purple" 14 | | "violet" 15 | | "orange" 16 | | "green" 17 | | "teal" 18 | | "cyan" 19 | | "emerald"; 20 | 21 | const badgeVariants: Record = { 22 | amber: "text-amber-600 bg-amber-50 ring-amber-600/20", 23 | red: "text-red-700 bg-red-50 ring-red-600/10", 24 | gray: "text-gray-600 bg-gray-50 ring-gray-500/10", 25 | yellow: "text-yellow-800 bg-yellow-50 ring-yellow-600/20", 26 | blue: "text-blue-700 bg-blue-50 ring-blue-700/10", 27 | indigo: "text-indigo-600 bg-indigo-50 ring-indigo-600/10", 28 | sky: "text-sky-700 bg-sky-50 ring-sky-700/10", 29 | rose: "text-rose-700 bg-rose-50 ring-rose-700/10", 30 | pink: "text-pink-700 bg-pink-50 ring-pink-700/10", 31 | purple: "text-purple-700 bg-purple-50 ring-purple-700/10", 32 | violet: "text-violet-700 bg-violet-50 ring-violet-700/10", 33 | orange: "text-orange-700 bg-orange-50 ring-orange-700/10", 34 | green: "text-green-700 bg-green-50 ring-green-700/10", 35 | teal: "text-teal-700 bg-teal-50 ring-teal-700/10", 36 | cyan: "text-cyan-700 bg-cyan-50 ring-cyan-700/10", 37 | emerald: "text-emerald-700 bg-emerald-50 ring-emerald-700/10", 38 | }; 39 | 40 | interface BadgeProps { 41 | text: string; 42 | variant: BadgeVariant; 43 | className?: string; 44 | props?: React.HTMLProps; 45 | } 46 | 47 | export const Badge: React.FC = ({ 48 | text, 49 | variant, 50 | className, 51 | props, 52 | }) => { 53 | const variantClasses = badgeVariants[variant] || badgeVariants["gray"]; 54 | 55 | return ( 56 | 63 | {text} 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/data-table/data-table-column-header.tsx: -------------------------------------------------------------------------------- 1 | import { Column } from "@tanstack/react-table"; 2 | // import { ArrowDownIcon, ArrowUpIcon, SortAsc } from "lucide-react"; 3 | 4 | // import { Button } from "@/components/ui/button"; 5 | import { cn } from "@/utils"; 6 | 7 | interface DataTableColumnHeaderProps 8 | extends React.HTMLAttributes { 9 | column: Column; 10 | title?: string | undefined; 11 | canSort?: boolean; 12 | } 13 | 14 | export function DataTableColumnHeader({ 15 | column, 16 | title, 17 | className, 18 | }: // canSort = true, 19 | DataTableColumnHeaderProps) { 20 | if (!column.getCanSort()) { 21 | return
{title}
; 22 | } 23 | 24 | return ( 25 |
26 | {(title?.length ?? 0) > 0 ? {title} : null} 27 | {/* {canSort && ( 28 | 42 | )} */} 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/data-table/pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Pagination } from "@/types/apis"; 3 | import { cn } from "@/utils"; 4 | import { 5 | Dropdown, 6 | DropdownItem, 7 | DropdownMenu, 8 | DropdownTrigger, 9 | } from "@nextui-org/react"; 10 | import { 11 | ArrowLeftToLine, 12 | ArrowRightFromLine, 13 | ChevronLeftIcon, 14 | ChevronRightIcon, 15 | } from "lucide-react"; 16 | import { useEffect, useState } from "react"; 17 | 18 | interface PaginationControlsProps { 19 | entityPlural: string; 20 | pageNumber: number; 21 | apiPagination: Pagination | undefined; 22 | pageSize: number; 23 | setPageSize: (size: number) => void; 24 | setPageNumber: (page: number) => void; 25 | className?: string; 26 | orientation?: "between" | "left" | "right"; 27 | } 28 | 29 | const PageSizeOptions = [10, 20, 50, 100, 1000, 2000, 5000] as const; 30 | // type PageSize = (typeof PageSizeOptions)[number]; 31 | export function PaginationControls({ 32 | entityPlural, 33 | pageNumber, 34 | apiPagination, 35 | pageSize, 36 | setPageSize, 37 | setPageNumber, 38 | className, 39 | orientation = "between", 40 | }: PaginationControlsProps) { 41 | const [totalPages, setTotalPages] = useState(apiPagination?.totalPages); 42 | const pageInfo = `${pageNumber} ${ 43 | totalPages ? `of ${totalPages}` : "of ..." 44 | }`; 45 | 46 | useEffect(() => { 47 | if (apiPagination?.totalPages != null) 48 | setTotalPages(apiPagination?.totalPages); 49 | }, [apiPagination?.totalPages, totalPages]); 50 | 51 | return ( 52 |
62 |
68 | 69 |
Page
70 | 71 | {pageInfo} 72 | 73 |
74 | 75 | {pageSize !== undefined && ( 76 |
77 | { 80 | setPageSize(Number(value)); 81 | setTotalPages(undefined); 82 | setPageNumber(1); 83 | }} 84 | /> 85 |
86 | )} 87 |
88 | 89 |
90 | 99 | 112 | 125 | 138 |
139 |
140 | ); 141 | } 142 | 143 | function PaginationCombobox({ 144 | value, 145 | onValueChange, 146 | }: { 147 | value: string; 148 | onValueChange: (value: string) => void; 149 | }) { 150 | const [open, setOpen] = useState(false); 151 | const pageSizes = PageSizeOptions.map((selectedPageSize) => ({ 152 | value: selectedPageSize.toString(), 153 | label: `Show ${selectedPageSize}`, 154 | })); 155 | 156 | return ( 157 | 158 | 159 | 164 | 165 | 166 | {pageSizes.map((pageSize) => ( 167 | { 170 | onValueChange(pageSize.value === value ? "" : pageSize.value); 171 | setOpen(false); 172 | }} 173 | > 174 | {pageSize.label} 175 | 176 | ))} 177 | 178 | 179 | ); 180 | } 181 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/data-table/trash-button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/utils"; 2 | import { Trash2Icon } from "lucide-react"; 3 | import { MouseEvent } from "react"; 4 | 5 | interface TrashButtonProps { 6 | onClick: (e: MouseEvent) => void; 7 | hideInGroup?: boolean; 8 | } 9 | 10 | export function TrashButton({ onClick, hideInGroup = true }: TrashButtonProps) { 11 | return ( 12 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/data-table/types/index.ts: -------------------------------------------------------------------------------- 1 | import { SortingState } from "@tanstack/react-table"; 2 | 3 | export interface PaginatedTableStore { 4 | setPageNumber: (page: number) => void; 5 | pageNumber: number; 6 | pageSize: number; 7 | setPageSize: (size: number) => void; 8 | sorting: SortingState; 9 | setSorting: React.Dispatch>; 10 | initialPageSize: number; 11 | isFiltered: { result: () => boolean }; 12 | resetFilters: () => void; 13 | queryKit: { 14 | filterValue: () => string; 15 | filterText: () => string; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/json-syntax-highlighter.tsx: -------------------------------------------------------------------------------- 1 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 2 | import { coy as style } from "react-syntax-highlighter/dist/esm/styles/prism"; 3 | 4 | export const JsonSyntaxHighlighter = ({ json }: { json: string }) => { 5 | try { 6 | const formattedJson = JSON.stringify(JSON.parse(json), null, 2); 7 | return ( 8 | 9 | {formattedJson} 10 | 11 | ); 12 | } catch (error) { 13 | return
Error displaying JSON
; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/loading-spinner.tsx: -------------------------------------------------------------------------------- 1 | export function LoadingSpinner() { 2 | return ( 3 | 9 | 17 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./notifications"; 2 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/notifications/notifications.tsx: -------------------------------------------------------------------------------- 1 | import toast, { Toaster, ToastOptions } from "react-hot-toast"; 2 | 3 | export const Notification = () => { 4 | return ( 5 | 26 | ); 27 | }; 28 | 29 | Notification.success = (message: string, options?: ToastOptions) => { 30 | toast.success(message, options); 31 | // toast.custom( 32 | // (t) => ( 33 | // // TODO framer motion 34 | //
35 | // Hello TailwindCSS! 👋 36 | //
37 | // ), 38 | // { 39 | // duration: 1500, 40 | // } 41 | // ); 42 | }; 43 | Notification.error = (message: string, options?: ToastOptions) => { 44 | toast.error(message, options); 45 | }; 46 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/ui/badge-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-emerald-500 text-white hover:bg-emerald-500/80", 13 | secondary: 14 | "border-transparent bg-zinc-100 text-zinc-800 hover:bg-zinc-100/80", 15 | destructive: 16 | "border-transparent bg-rose-500 text-blue-950 hover:bg-rose-500/80", 17 | outline: "text-slate-900", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function BadgeAvatar({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { BadgeAvatar, badgeVariants }; 37 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-emerald-500 text-white hover:bg-emerald-500/90", 13 | destructive: 14 | "hover:bg-rose-200 hover:text-rose-800 dark:border-slate-900 dark:bg-slate-800 dark:text-white dark:hover:bg-rose-800 dark:hover:text-rose-300 dark:shadow-rose-400 dark:hover:shadow-rose-300", 15 | outline: 16 | "border border-input bg-background hover:bg-zinc-100 hover:text-zinc-900", 17 | secondary: 18 | "hover:bg-slate-200 hover:text-slate-800 dark:border-slate-900 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-800 dark:hover:text-slate-300 dark:shadow-slate-400 dark:hover:shadow-slate-300", 19 | ghost: "hover:bg-gray-100 hover:text-gray-900", 20 | link: "text-emerald-500 underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | } 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Modal, ModalContent } from "@nextui-org/react"; 4 | import { Command as CommandPrimitive } from "cmdk"; 5 | import { Search } from "lucide-react"; 6 | import * as React from "react"; 7 | 8 | import { cn } from "@/utils"; 9 | 10 | const Command = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | Command.displayName = CommandPrimitive.displayName; 24 | 25 | // interface CommandModalProps extends ModalProps {} 26 | interface CommandModalProps { 27 | children: React.ReactNode; 28 | } 29 | 30 | const CommandModal = ({ children, ...props }: CommandModalProps) => { 31 | return ( 32 | 33 | 34 | 35 | {children} 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | const CommandInput = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef 45 | >(({ className, ...props }, ref) => ( 46 |
47 | 48 | 56 |
57 | )); 58 | 59 | CommandInput.displayName = CommandPrimitive.Input.displayName; 60 | 61 | const CommandList = React.forwardRef< 62 | React.ElementRef, 63 | React.ComponentPropsWithoutRef 64 | >(({ className, ...props }, ref) => ( 65 | 70 | )); 71 | 72 | CommandList.displayName = CommandPrimitive.List.displayName; 73 | 74 | const CommandEmpty = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef 77 | >((props, ref) => ( 78 | 83 | )); 84 | 85 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName; 86 | 87 | const CommandGroup = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => ( 91 | 99 | )); 100 | 101 | CommandGroup.displayName = CommandPrimitive.Group.displayName; 102 | 103 | const CommandSeparator = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )); 113 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName; 114 | 115 | const CommandItem = React.forwardRef< 116 | React.ElementRef, 117 | React.ComponentPropsWithoutRef 118 | >(({ className, ...props }, ref) => ( 119 | 127 | )); 128 | 129 | CommandItem.displayName = CommandPrimitive.Item.displayName; 130 | 131 | const CommandShortcut = ({ 132 | className, 133 | ...props 134 | }: React.HTMLAttributes) => { 135 | return ( 136 | 143 | ); 144 | }; 145 | CommandShortcut.displayName = "CommandShortcut"; 146 | 147 | export { 148 | Command, 149 | CommandEmpty, 150 | CommandGroup, 151 | CommandInput, 152 | CommandItem, 153 | CommandList, 154 | CommandModal, 155 | CommandSeparator, 156 | CommandShortcut, 157 | }; 158 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/ui/copy-button.tsx: -------------------------------------------------------------------------------- 1 | import { Notification } from "@/components/notifications"; 2 | import { cn } from "@/utils"; 3 | 4 | export function CopyButton({ 5 | textToCopy, 6 | className, 7 | }: { 8 | textToCopy: string; 9 | className?: string; 10 | }) { 11 | return ( 12 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/utils"; 2 | import * as React from "react"; 3 | 4 | const Table = React.forwardRef< 5 | HTMLTableElement, 6 | React.HTMLAttributes 7 | >(({ className, ...props }, ref) => ( 8 |
9 | 14 | 15 | )); 16 | Table.displayName = "Table"; 17 | 18 | const TableHeader = React.forwardRef< 19 | HTMLTableSectionElement, 20 | React.HTMLAttributes 21 | >(({ className, ...props }, ref) => ( 22 | 23 | )); 24 | TableHeader.displayName = "TableHeader"; 25 | 26 | const TableBody = React.forwardRef< 27 | HTMLTableSectionElement, 28 | React.HTMLAttributes 29 | >(({ className, ...props }, ref) => ( 30 | 35 | )); 36 | TableBody.displayName = "TableBody"; 37 | 38 | const TableFooter = React.forwardRef< 39 | HTMLTableSectionElement, 40 | React.HTMLAttributes 41 | >(({ className, ...props }, ref) => ( 42 | 47 | )); 48 | TableFooter.displayName = "TableFooter"; 49 | 50 | type TableRowProps = { 51 | onRowClick?: ( 52 | event: React.MouseEvent 53 | ) => void | undefined; 54 | } & React.HTMLAttributes; 55 | 56 | const TableRow = React.forwardRef( 57 | ({ className, onRowClick, ...props }, ref) => { 58 | const rowIsClickable = !!onRowClick; 59 | return ( 60 | 70 | ); 71 | } 72 | ); 73 | TableRow.displayName = "TableRow"; 74 | 75 | const TableHead = React.forwardRef< 76 | HTMLTableCellElement, 77 | React.ThHTMLAttributes 78 | >(({ className, ...props }, ref) => ( 79 |
87 | )); 88 | TableHead.displayName = "TableHead"; 89 | 90 | const TableCell = React.forwardRef< 91 | HTMLTableCellElement, 92 | React.TdHTMLAttributes 93 | >(({ className, ...props }, ref) => ( 94 | 99 | )); 100 | TableCell.displayName = "TableCell"; 101 | 102 | const TableCaption = React.forwardRef< 103 | HTMLTableCaptionElement, 104 | React.HTMLAttributes 105 | >(({ className, ...props }, ref) => ( 106 |
111 | )); 112 | TableCaption.displayName = "TableCaption"; 113 | 114 | export { 115 | Table, 116 | TableBody, 117 | TableCaption, 118 | TableCell, 119 | TableFooter, 120 | TableHead, 121 | TableHeader, 122 | TableRow, 123 | }; 124 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/apis/delete-jobs.tsx: -------------------------------------------------------------------------------- 1 | import { isStandaloneEnv } from "@/utils"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | import axios from "axios"; 4 | import { JobKeys } from "./job.keys"; 5 | 6 | export const deleteJobs = async (jobIds: string[]) => { 7 | const url = isStandaloneEnv() 8 | ? `http://localhost:5130/api/v1/jobs/bulkDelete` 9 | : `/api/v1/jobs/bulkDelete`; 10 | return axios.post(url, { 11 | jobIds: jobIds, 12 | }); 13 | }; 14 | 15 | export const useDeleteJobs = () => { 16 | const queryClient = useQueryClient(); 17 | 18 | return useMutation({ 19 | mutationFn: deleteJobs, 20 | onSuccess: () => { 21 | queryClient.invalidateQueries({ queryKey: JobKeys.all }); 22 | }, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/apis/get-job-view.tsx: -------------------------------------------------------------------------------- 1 | import { isStandaloneEnv } from "@/utils"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import axios, { AxiosResponse } from "axios"; 4 | import { TaskTowerJobView } from "../types"; 5 | import { JobKeys } from "./job.keys"; 6 | 7 | export const getJobView = async (jobId: string) => { 8 | const url = isStandaloneEnv() 9 | ? `http://localhost:5130/api/v1/jobs/${jobId}/view` 10 | : `/api/v1/jobs/${jobId}/view`; 11 | return axios 12 | .get(url) 13 | .then((response: AxiosResponse) => response.data); 14 | }; 15 | 16 | export const useGetJobView = (jobId: string) => { 17 | return useQuery({ 18 | queryKey: JobKeys.detail(jobId), 19 | queryFn: () => getJobView(jobId), 20 | enabled: !!jobId, 21 | gcTime: 0, 22 | staleTime: 0, 23 | refetchInterval: (data) => { 24 | const status = data.state?.data?.job.status; 25 | return status != "Pending" && status != "Completed" ? 3000 : false; 26 | }, 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/apis/get-jobs-worklist.tsx: -------------------------------------------------------------------------------- 1 | import { Job } from "@/domain/jobs/types"; 2 | import { PagedResponse, Pagination } from "@/types/apis"; 3 | import { isStandaloneEnv } from "@/utils"; 4 | import { useQuery, useQueryClient } from "@tanstack/react-query"; 5 | import { SortingState } from "@tanstack/react-table"; 6 | import axios, { AxiosResponse } from "axios"; 7 | import queryString from "query-string"; 8 | import { JobKeys } from "./job.keys"; 9 | 10 | interface DelayProps { 11 | hasArtificialDelay?: boolean; 12 | delayInMs?: number; 13 | } 14 | 15 | interface JobsListApiProps extends DelayProps { 16 | queryString: string; 17 | } 18 | 19 | const getJobs = async ({ 20 | queryString, 21 | hasArtificialDelay = false, 22 | delayInMs = 0, 23 | }: JobsListApiProps) => { 24 | const url = isStandaloneEnv() 25 | ? `http://localhost:5130/api/v1/jobs/paginated?${queryString}` 26 | : `/api/v1/jobs/paginated?${queryString}`; 27 | 28 | const [result] = await Promise.all([ 29 | axios.get(url).then((response: AxiosResponse) => { 30 | const jobs: Job[] = response.data; 31 | const pagination: Pagination = JSON.parse( 32 | response.headers["x-pagination"] ?? "{}" 33 | ); 34 | return { 35 | data: jobs, 36 | pagination, 37 | } as PagedResponse; 38 | }), 39 | new Promise((resolve) => 40 | setTimeout(resolve, hasArtificialDelay ? delayInMs : 0) 41 | ), 42 | ]); 43 | 44 | return result; 45 | }; 46 | 47 | interface JobsListHookProps extends DelayProps { 48 | pageNumber?: number; 49 | pageSize?: number; 50 | sortOrder?: SortingState; 51 | status?: string[]; 52 | filterText?: string; 53 | queue?: string[]; 54 | } 55 | 56 | export const useJobs = ({ 57 | pageNumber = 1, 58 | pageSize = 10, 59 | sortOrder, 60 | delayInMs = 0, 61 | status, 62 | filterText, 63 | queue, 64 | }: JobsListHookProps = {}) => { 65 | const queryParams = queryString.stringify({ 66 | pageNumber, 67 | pageSize, 68 | filterText: filterText && filterText.length > 0 ? filterText : undefined, 69 | statusFilter: 70 | status && status.length > 0 71 | ? // ? status?.map((status) => `StatusFilter=${status}`).join("&") 72 | status 73 | : undefined, 74 | queueFilter: 75 | queue && queue.length > 0 76 | ? // ? queue?.map((queue) => `StatusFilter=${queue}`).join("&") 77 | queue 78 | : undefined, 79 | sortOrder: generateSieveSortOrder(sortOrder), 80 | }); 81 | 82 | const hasArtificialDelay = delayInMs > 0; 83 | 84 | return useQuery({ 85 | queryKey: JobKeys.list(queryParams), 86 | queryFn: () => 87 | getJobs({ 88 | queryString: queryParams, 89 | hasArtificialDelay, 90 | delayInMs, 91 | }), 92 | gcTime: 0, 93 | staleTime: 0, 94 | }); 95 | }; 96 | 97 | // Assuming SortingState is defined elsewhere 98 | // Adapt this function based on your actual sorting logic 99 | export const generateSieveSortOrder = (sortOrder: SortingState | undefined) => 100 | sortOrder && sortOrder.length > 0 101 | ? sortOrder.map((s) => (s.desc ? `-${s.id}` : s.id)).join(",") 102 | : undefined; 103 | 104 | export const useInvalidateJobListQuery = () => { 105 | const queryClient = useQueryClient(); 106 | 107 | const invalidateJobListQuery = () => { 108 | const queryKey = JobKeys.lists(); 109 | queryClient.invalidateQueries({ queryKey }); 110 | }; 111 | 112 | return invalidateJobListQuery; 113 | }; 114 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/apis/get-queue-names.tsx: -------------------------------------------------------------------------------- 1 | import { isStandaloneEnv } from "@/utils"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import axios, { AxiosResponse } from "axios"; 4 | import { JobKeys } from "./job.keys"; 5 | 6 | export const getQueueNames = async () => { 7 | const url = isStandaloneEnv() 8 | ? `http://localhost:5130/api/v1/jobs/queueNames` 9 | : `/api/v1/jobs/queueNames`; 10 | return axios 11 | .get(url) 12 | .then((response: AxiosResponse) => response.data); 13 | }; 14 | 15 | export const useGetQueueNames = () => { 16 | return useQuery({ 17 | queryKey: JobKeys.queueNames(), 18 | queryFn: () => getQueueNames(), 19 | gcTime: 0, 20 | staleTime: 0, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/apis/job.keys.ts: -------------------------------------------------------------------------------- 1 | const JobKeys = { 2 | all: ["Jobs"] as const, 3 | lists: () => [...JobKeys.all, "list"] as const, 4 | list: (queryParams: string) => [...JobKeys.lists(), { queryParams }] as const, 5 | details: () => [...JobKeys.all, "detail"] as const, 6 | detail: (accessionId: string) => [...JobKeys.details(), accessionId] as const, 7 | queueNames: () => [...JobKeys.all, "queueNames"] as const, 8 | }; 9 | 10 | export { JobKeys }; 11 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/apis/requeue-job.tsx: -------------------------------------------------------------------------------- 1 | import { isStandaloneEnv } from "@/utils"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | import axios from "axios"; 4 | import { JobKeys } from "./job.keys"; 5 | 6 | export const requeueJobs = async (jobId: string) => { 7 | const url = isStandaloneEnv() 8 | ? `http://localhost:5130/api/v1/jobs/${jobId}/requeue` 9 | : `/api/v1/jobs/${jobId}/requeue`; 10 | return axios.put(url); 11 | }; 12 | 13 | export const useRequeueJob = () => { 14 | const queryClient = useQueryClient(); 15 | 16 | return useMutation({ 17 | mutationFn: requeueJobs, 18 | onSuccess: () => { 19 | queryClient.invalidateQueries({ queryKey: JobKeys.all }); 20 | }, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/components/job-status.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, BadgeVariant } from "../../../components/badge"; 2 | import { JobStatus } from "../types"; 3 | 4 | interface JobStatusBadgeProps { 5 | status: JobStatus; 6 | className?: string; 7 | props?: React.HTMLProps; 8 | } 9 | 10 | export const JobStatusBadge: React.FC = ({ 11 | status, 12 | className, 13 | props, 14 | }) => { 15 | let variant: BadgeVariant; 16 | 17 | switch (status) { 18 | case "Pending": 19 | variant = "cyan"; 20 | break; 21 | case "Enqueued": 22 | variant = "indigo"; 23 | break; 24 | case "Processing": 25 | variant = "sky"; 26 | break; 27 | case "Failed": 28 | variant = "rose"; 29 | break; 30 | case "Completed": 31 | variant = "green"; 32 | break; 33 | case "Dead": 34 | variant = "amber"; 35 | break; 36 | default: 37 | variant = "gray"; 38 | } 39 | 40 | return ( 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/components/worklist/job-queue-filter-control.tsx: -------------------------------------------------------------------------------- 1 | import { PlusCircleIcon } from "lucide-react"; 2 | import * as React from "react"; 3 | 4 | import { BadgeAvatar } from "@/components/ui/badge-avatar"; 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Command, 8 | CommandEmpty, 9 | CommandGroup, 10 | CommandInput, 11 | CommandItem, 12 | CommandList, 13 | CommandSeparator, 14 | } from "@/components/ui/command"; 15 | import { cn } from "@/utils"; 16 | import { Popover, PopoverContent, PopoverTrigger } from "@nextui-org/react"; 17 | import { CheckIcon } from "lucide-react"; 18 | import { useJobsTableStore } from "./jobs-worklist.store"; 19 | 20 | interface FilterControl { 21 | title?: string; 22 | options: { 23 | label: string; 24 | value: string; 25 | }[]; 26 | } 27 | 28 | export function QueueFilterControl({ title, options }: FilterControl) { 29 | const { addQueue, removeQueue, queue, clearQueue } = useJobsTableStore(); 30 | const selectedValues = new Set(queue); 31 | const [popoverIsOpen, setPopoverIsOpen] = React.useState(false); 32 | 33 | return ( 34 | setPopoverIsOpen(open)} 38 | > 39 | 40 | 77 | 78 | 79 | 80 | 81 | 82 | No results found. 83 | 84 | {options.map((option) => { 85 | const isSelected = selectedValues.has(option.value); 86 | return ( 87 | { 90 | if (isSelected) { 91 | removeQueue(option.value); 92 | selectedValues.delete(option.value); 93 | } else { 94 | addQueue(option.value); 95 | selectedValues.add(option.value); 96 | } 97 | }} 98 | > 99 |
107 | 108 |
109 | {option.label} 110 |
111 | ); 112 | })} 113 |
114 | {selectedValues.size > 0 && ( 115 | <> 116 | 117 | 118 | { 120 | clearQueue(); 121 | setPopoverIsOpen(false); 122 | }} 123 | className="justify-center text-center" 124 | > 125 | Clear filters 126 | 127 | 128 | 129 | )} 130 |
131 |
132 |
133 |
134 | ); 135 | } 136 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/components/worklist/job-status-filter-control.tsx: -------------------------------------------------------------------------------- 1 | import { PlusCircleIcon } from "lucide-react"; 2 | import * as React from "react"; 3 | 4 | import { BadgeAvatar } from "@/components/ui/badge-avatar"; 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Command, 8 | CommandEmpty, 9 | CommandGroup, 10 | CommandInput, 11 | CommandItem, 12 | CommandList, 13 | CommandSeparator, 14 | } from "@/components/ui/command"; 15 | import { cn } from "@/utils"; 16 | import { Popover, PopoverContent, PopoverTrigger } from "@nextui-org/react"; 17 | import { CheckIcon } from "lucide-react"; 18 | import { useJobsTableStore } from "./jobs-worklist.store"; 19 | 20 | interface FilterControl { 21 | title?: string; 22 | options: { 23 | label: string; 24 | value: string; 25 | icon?: React.ElementType; 26 | }[]; 27 | } 28 | 29 | export function FilterControl({ title, options }: FilterControl) { 30 | const { addStatus, removeStatus, status, clearStatus } = useJobsTableStore(); 31 | const selectedValues = new Set(status); 32 | const [popoverIsOpen, setPopoverIsOpen] = React.useState(false); 33 | 34 | return ( 35 | setPopoverIsOpen(open)} 39 | > 40 | 41 | 78 | 79 | 80 | 81 | 82 | 83 | No results found. 84 | 85 | {options.map((option) => { 86 | const isSelected = selectedValues.has(option.value); 87 | return ( 88 | { 91 | if (isSelected) { 92 | removeStatus(option.value); 93 | selectedValues.delete(option.value); 94 | } else { 95 | addStatus(option.value); 96 | selectedValues.add(option.value); 97 | } 98 | }} 99 | > 100 |
108 | 109 |
110 | {option.icon && ( 111 | 112 | )} 113 | {option.label} 114 |
115 | ); 116 | })} 117 |
118 | {selectedValues.size > 0 && ( 119 | <> 120 | 121 | 122 | { 124 | clearStatus(); 125 | setPopoverIsOpen(false); 126 | }} 127 | className="justify-center text-center" 128 | > 129 | Clear filters 130 | 131 | 132 | 133 | )} 134 |
135 |
136 |
137 |
138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/components/worklist/jobs-worklist-columns.tsx: -------------------------------------------------------------------------------- 1 | import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; 2 | import { JobStatusBadge } from "@/domain/jobs/components/job-status"; 3 | import { Job, JobStatus } from "@/domain/jobs/types"; 4 | import { cn } from "@/utils"; 5 | import { Link } from "@tanstack/react-router"; 6 | import { ColumnDef } from "@tanstack/react-table"; 7 | import { formatDistanceToNow, parseISO } from "date-fns"; 8 | import React, { HTMLProps } from "react"; 9 | 10 | type Columns = ColumnDef; 11 | export const createColumns = (): Columns[] => [ 12 | { 13 | id: "selection", 14 | header: ({ table }) => ( 15 |
16 | 23 |
24 | ), 25 | cell: ({ row }) => ( 26 |
27 | 34 |
35 | ), 36 | }, 37 | { 38 | accessorKey: "id", 39 | header: "Id", 40 | }, 41 | { 42 | accessorKey: "job-identification", 43 | header: ({ column }) => ( 44 | 45 | ), 46 | cell: ({ row }) => { 47 | const id = row.getValue("id") as string; 48 | const jobName = row.getValue("jobName") as string; 49 | return ( 50 | //

51 | // {id} - {jobName} 52 | //

53 | 54 |
55 |
56 | 61 | {jobName} 62 | 63 |

{id}

64 |
65 |
66 | ); 67 | }, 68 | }, 69 | // { 70 | // accessorKey: "status", 71 | // header: "Status", 72 | // }, 73 | { 74 | accessorKey: "status", 75 | header: ({ column }) => ( 76 | 77 | ), 78 | cell: ({ row }) => { 79 | const status = row.getValue("status") as string; 80 | return ( 81 |

82 | {(status?.length ?? 0) > 0 ? ( 83 | 84 | ) : ( 85 | "—" 86 | )} 87 |

88 | ); 89 | }, 90 | }, 91 | { 92 | accessorKey: "jobName", 93 | header: "Job Name", 94 | }, 95 | { 96 | accessorKey: "queue", 97 | header: "Queue", 98 | }, 99 | { 100 | accessorKey: "payload", 101 | header: "Payload", 102 | }, 103 | { 104 | accessorKey: "retries", 105 | header: "Retries", 106 | }, 107 | { 108 | accessorKey: "maxRetries", 109 | header: "Max Retries", 110 | }, 111 | { 112 | accessorKey: "retried", 113 | header: ({ column }) => ( 114 | 115 | ), 116 | cell: ({ row }) => { 117 | const maxRetries = row.getValue("maxRetries") as number; 118 | const retries = row.getValue("retries") as number; 119 | const status = row.getValue("status") as string; 120 | 121 | if (status === "Completed") { 122 | return

; 123 | } 124 | 125 | const retriesRemaining = maxRetries - retries; 126 | 127 | return status === "Dead" || status === "Failed" ? ( 128 |

129 | {retries}/{maxRetries} 130 |

131 | ) : ( 132 |

{retriesRemaining >= 0 ? `${retries}/${maxRetries}` : "—"}

133 | ); 134 | }, 135 | }, 136 | { 137 | accessorKey: "runAfter", 138 | header: ({ column }) => ( 139 | 144 | ), 145 | cell: ({ row }) => { 146 | const runAfter = row.getValue("runAfter") as string; 147 | 148 | if (runAfter < new Date().toISOString()) { 149 | return

; 150 | } 151 | 152 | return ( 153 |

154 | {(runAfter?.length ?? 0) > 0 155 | ? formatDistanceToNow(parseISO(runAfter), { addSuffix: true }) 156 | : "—"} 157 |

158 | ); 159 | }, 160 | }, 161 | { 162 | accessorKey: "ranAt", 163 | header: ({ column }) => ( 164 | 165 | ), 166 | cell: ({ row }) => { 167 | const ranAt = row.getValue("ranAt") as string; 168 | 169 | if (!ranAt) { 170 | return

; 171 | } 172 | 173 | return ( 174 |

175 | {(ranAt?.length ?? 0) > 0 176 | ? formatDistanceToNow(parseISO(ranAt), { addSuffix: true }) 177 | : "—"} 178 |

179 | ); 180 | }, 181 | }, 182 | // { 183 | // accessorKey: "createdAt", 184 | // header: "Created At", 185 | // }, 186 | // { 187 | // accessorKey: "deadline", 188 | // header: "Deadline", 189 | // }, 190 | ]; 191 | 192 | // const formatCountdown = ({ isoDate }: { isoDate: string }) => { 193 | // const targetDate = parseISO(isoDate); 194 | // return formatDistanceToNow(targetDate, { addSuffix: true }); 195 | // }; 196 | 197 | // const formatCountdown = ({ isoDate }: { isoDate: string }) => { 198 | // const now = new Date(); 199 | // const targetDate = parseISO(isoDate); 200 | // const secondsDiff = differenceInSeconds(targetDate, now); 201 | 202 | // if (secondsDiff === 1) { 203 | // return `in 1 second`; 204 | // } 205 | 206 | // // If the difference is less than 60 seconds, format it as "in xx seconds" 207 | // if (secondsDiff >= 0 && secondsDiff <= 60) { 208 | // return `in ${secondsDiff} seconds`; 209 | // } 210 | 211 | // return formatDistanceToNow(targetDate, { addSuffix: true }); 212 | // }; 213 | 214 | // const useCountdown = ({ isoDate }: { isoDate: string }) => { 215 | // const [countdown, setCountdown] = useState(() => 216 | // formatCountdown({ isoDate }) 217 | // ); 218 | 219 | // useEffect(() => { 220 | // const intervalId = setInterval(() => { 221 | // setCountdown(formatCountdown({ isoDate })); 222 | // }, 1000); 223 | 224 | // return () => clearInterval(intervalId); 225 | // }, [isoDate]); 226 | 227 | // return countdown; 228 | // }; 229 | 230 | // const CountdownTimer = ({ isoDate }: { isoDate: string }) => { 231 | // const countdown = useCountdown({ isoDate }); 232 | 233 | // return
{countdown}
; 234 | // }; 235 | 236 | function IndeterminateCheckbox({ 237 | indeterminate, 238 | className = "", 239 | ...rest 240 | }: { indeterminate?: boolean } & HTMLProps) { 241 | const ref = React.useRef(null!); 242 | 243 | React.useEffect(() => { 244 | if (typeof indeterminate === "boolean") { 245 | ref.current.indeterminate = !rest.checked && indeterminate; 246 | } 247 | }, [ref, indeterminate, rest.checked]); 248 | 249 | return ( 250 | 256 | ); 257 | } 258 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/components/worklist/jobs-worklist.store.tsx: -------------------------------------------------------------------------------- 1 | import { PaginatedTableStore } from "@/components/data-table/types"; 2 | import { create } from "zustand"; 3 | 4 | // Assuming the types for your jobs' sorting and pagination logic are defined elsewhere 5 | // You might need to adapt these types according to your actual data structure and requirements 6 | interface JobsTableStore extends PaginatedTableStore { 7 | status: string[]; 8 | addStatus: (status: string) => void; 9 | removeStatus: (status: string) => void; 10 | clearStatus: () => void; 11 | queue: string[]; 12 | addQueue: (queue: string) => void; 13 | removeQueue: (queue: string) => void; 14 | clearQueue: () => void; 15 | filterInput: string | null; 16 | setFilterInput: (f: string | null) => void; 17 | } 18 | 19 | export const useJobsTableStore = create((set, get) => ({ 20 | initialPageSize: 10, 21 | pageNumber: 1, 22 | setPageNumber: (page) => set({ pageNumber: page }), 23 | pageSize: 10, 24 | setPageSize: (size) => set({ pageSize: size }), 25 | sorting: [], 26 | setSorting: (sortOrUpdater) => { 27 | if (typeof sortOrUpdater === "function") { 28 | set((prevState) => ({ sorting: sortOrUpdater(prevState.sorting) })); 29 | } else { 30 | set({ sorting: sortOrUpdater }); 31 | } 32 | }, 33 | status: [], 34 | addStatus: (status) => 35 | set((prevState) => ({ status: [...prevState.status, status] })), 36 | removeStatus: (status) => 37 | set((prevState) => ({ 38 | status: prevState.status.filter((s) => s !== status), 39 | })), 40 | clearStatus: () => set({ status: [] }), 41 | queue: [], 42 | addQueue: (queue) => 43 | set((prevState) => ({ queue: [...prevState.queue, queue] })), 44 | removeQueue: (queue) => 45 | set((prevState) => ({ 46 | queue: prevState.queue.filter((s) => s !== queue), 47 | })), 48 | clearQueue: () => set({ queue: [] }), 49 | filterInput: null, 50 | setFilterInput: (f) => set({ filterInput: f }), 51 | isFiltered: { 52 | result: () => 53 | get().status.length > 0 || 54 | (get().filterInput?.length ?? 0) > 0 || 55 | get().queue.length > 0, 56 | }, 57 | resetFilters: () => set({ status: [], filterInput: null, queue: [] }), 58 | queryKit: { 59 | // filterValue: () => { 60 | // const statusFilter = get() 61 | // .status.map((status) => `status == "${status}"`) 62 | // .join(" || "); 63 | // const jobIdFilter = get().filterInput 64 | // ? `jobId @=* "${get().filterInput}"` 65 | // : ""; 66 | // if (statusFilter && jobIdFilter) { 67 | // return `${statusFilter} && ${jobIdFilter}`; 68 | // } 69 | // if (statusFilter.length > 0) return statusFilter; 70 | // if (jobIdFilter.length > 0) return jobIdFilter; 71 | // return ""; 72 | // }, 73 | filterValue: () => { 74 | return ""; 75 | }, 76 | filterText: () => { 77 | const jobIdFilter = get().filterInput ? `${get().filterInput}` : ""; 78 | if (jobIdFilter.length > 0) return jobIdFilter; 79 | return ""; 80 | }, 81 | }, 82 | })); 83 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/domain/jobs/types.ts: -------------------------------------------------------------------------------- 1 | export type JobStatus = 2 | | "Pending" 3 | | "Enqueued" 4 | | "Processing" 5 | | "Completed" 6 | | "Failed" 7 | | "Dead"; 8 | 9 | export interface Job { 10 | id: string; 11 | jobName: string; 12 | queue: string | null; 13 | status: JobStatus; 14 | type: string; 15 | method: string; 16 | parameterTypes: string[]; 17 | payload: string; 18 | retries: number; 19 | maxRetries: number | null; 20 | runAfter: Date; 21 | ranAt: Date | null; 22 | createdAt: Date; 23 | deadline: Date | null; 24 | } 25 | 26 | export type TaskTowerJobView = { 27 | job: { 28 | id: string; 29 | queue: string | null; 30 | status: string; 31 | type: string; 32 | method: string; 33 | parameterTypes: string[]; 34 | payload: string; 35 | retries: number; 36 | maxRetries: number | null; 37 | runAfter: Date; 38 | ranAt: Date | null; 39 | createdAt: Date; 40 | deadline: Date | null; 41 | jobName: string; 42 | tagNames: string | null; 43 | tags: string[]; 44 | }; 45 | history: { 46 | id: string; 47 | jobId: string; 48 | status: string; 49 | comment: string | null; 50 | details: string | null; 51 | occurredAt: Date | null; 52 | }[]; 53 | }; 54 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/global.d.ts: -------------------------------------------------------------------------------- 1 | // global.d.ts 2 | declare global { 3 | interface Window { 4 | ASPNETCORE_ENVIRONMENT?: string; 5 | } 6 | } 7 | 8 | export {}; 9 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/hooks/useDebouncedValue.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | 3 | export function useDebouncedValue( 4 | value: T, 5 | wait: number, 6 | options = { leading: false } 7 | ) { 8 | const [_value, setValue] = useState(value); 9 | const mountedRef = useRef(false); 10 | const timeoutRef = useRef(undefined); 11 | const cooldownRef = useRef(false); 12 | 13 | const cancel = () => { 14 | if (timeoutRef.current !== undefined) { 15 | window.clearTimeout(timeoutRef.current); 16 | } 17 | }; 18 | 19 | useEffect(() => { 20 | if (mountedRef.current) { 21 | if (!cooldownRef.current && options.leading) { 22 | cooldownRef.current = true; 23 | setValue(value); 24 | } else { 25 | cancel(); 26 | timeoutRef.current = window.setTimeout(() => { 27 | cooldownRef.current = false; 28 | setValue(value); 29 | }, wait); 30 | } 31 | } 32 | }, [value, options.leading, wait]); 33 | 34 | useEffect(() => { 35 | mountedRef.current = true; 36 | return cancel; 37 | }, []); 38 | 39 | return [_value, cancel] as const; 40 | } 41 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/layouts/auth-layout.tsx: -------------------------------------------------------------------------------- 1 | import logo from "@/assets/logo.svg"; 2 | import { LoadingSpinner } from "@/components/loading-spinner"; 3 | import { AllRoutesPaths } from "@/router"; 4 | 5 | import { cn } from "@/utils"; 6 | import { BriefcaseIcon } from "@heroicons/react/24/outline"; 7 | import { Link, Outlet } from "@tanstack/react-router"; 8 | import { LayoutDashboard } from "lucide-react"; 9 | 10 | type NavType = { 11 | name: string; 12 | href: AllRoutesPaths; 13 | icon: React.FC; 14 | }; 15 | 16 | const navigation = [ 17 | { name: "Dashboard", href: "/tasktower", icon: LayoutDashboard }, 18 | { 19 | name: "Jobs", 20 | href: "/tasktower/jobs", 21 | icon: BriefcaseIcon, 22 | }, 23 | ] as NavType[]; 24 | 25 | export default function AuthLayout() { 26 | // const { user, logoutUrl, isLoading } = useAuthUser(); 27 | const isLoading = false; 28 | if (isLoading) return ; 29 | 30 | return ( 31 | <> 32 |
33 | 34 | 35 |
36 |
37 |
{/* */}
38 |

39 | Task Tower 40 |

41 |
42 |
43 | 44 |
45 |
46 | 47 |
48 |
49 |
{/* */}
50 |
51 | 52 | ); 53 | } 54 | 55 | const sideNavWidth = "lg:w-52"; 56 | 57 | function DesktopMenu() { 58 | return ( 59 |
65 | {/* Sidebar component, swap this element with another sidebar if you like */} 66 |
67 |
68 | 69 |
70 | Task Tower 76 |

Task Tower

77 |
78 | 79 |
80 | 150 |
151 |
152 | ); 153 | } 154 | 155 | function Loading() { 156 | return ( 157 |
158 | 159 |
160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/lib/dev-tools.tsx: -------------------------------------------------------------------------------- 1 | import { getEnv } from "@/utils"; 2 | import React from "react"; 3 | 4 | export const TanStackRouterDevtools = 5 | getEnv() === "Production" 6 | ? () => null // Render nothing in production 7 | : React.lazy(() => 8 | // Lazy load in development 9 | import("@tanstack/router-devtools").then((res) => ({ 10 | default: res.TanStackRouterDevtools, 11 | // For Embedded Mode 12 | // default: res.TanStackRouterDevtoolsPanel 13 | })) 14 | ); 15 | 16 | export const ReactQueryDevtools = 17 | getEnv() === "Production" 18 | ? () => null // Render nothing in production 19 | : React.lazy(() => 20 | // Lazy load in development 21 | import("@tanstack/react-query-devtools").then((res) => ({ 22 | default: res.ReactQueryDevtools, 23 | // For Embedded Mode 24 | // default: res.TanStackRouterDevtoolsPanel 25 | })) 26 | ); 27 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/lib/site-config.ts: -------------------------------------------------------------------------------- 1 | interface SiteConfig { 2 | name: string; 3 | // description: string; 4 | // url: string; 5 | // ogImage: string; 6 | // links: { 7 | // twitter: string; 8 | // github: string; 9 | // }; 10 | } 11 | 12 | export const siteConfig: SiteConfig = { 13 | name: "Task Tower", 14 | // description: 15 | // url: "http://tasktower.com", 16 | // ogImage: "", 17 | // links: { 18 | // twitter: "https://twitter.com/tasktower", 19 | // github: "https://github.com/tasktower/tasktower", 20 | // }, 21 | }; 22 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { NextUIProvider } from "@nextui-org/react"; 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 3 | import { RouterProvider } from "@tanstack/react-router"; 4 | import { StrictMode, Suspense } from "react"; 5 | import ReactDOM from "react-dom/client"; 6 | import "./index.css"; 7 | import { router } from "./router"; 8 | 9 | const queryClient = new QueryClient(); 10 | 11 | ReactDOM.createRoot(document.getElementById("root")!).render( 12 | 13 | 14 | 15 | 16 | {" "} 17 | 18 | 19 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from "react-helmet"; 2 | 3 | export function IndexPage() { 4 | return ( 5 | <> 6 |
7 | 8 | Dashboard 9 | 10 | Coming soon... 11 |
12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/pages/jobs/index.tsx: -------------------------------------------------------------------------------- 1 | import { useJobs } from "@/domain/jobs/apis/get-jobs-worklist"; 2 | import { JobsWorklist } from "@/domain/jobs/components/worklist/jobs-worklist"; 3 | import { createColumns } from "@/domain/jobs/components/worklist/jobs-worklist-columns"; 4 | import { useJobsTableStore } from "@/domain/jobs/components/worklist/jobs-worklist.store"; 5 | 6 | import { Helmet } from "react-helmet"; 7 | 8 | export function JobsWorklistPage() { 9 | // const environment = getEnv(); 10 | return ( 11 | <> 12 | 13 | Jobs 14 | 15 | 16 |
17 | {/*

18 | Hello Task Tower in ({environment}) 19 |

*/} 20 |

Jobs

21 | 22 |
23 | 24 | ); 25 | } 26 | 27 | function Jobs() { 28 | const { sorting, pageSize, pageNumber, queryKit, status, queue } = 29 | useJobsTableStore(); 30 | const { data: jobs, isLoading } = useJobs({ 31 | sortOrder: sorting, 32 | pageSize, 33 | pageNumber, 34 | // filters: queryKit.filterValue(), 35 | delayInMs: 450, 36 | status, 37 | queue, 38 | filterText: queryKit.filterText(), 39 | }); 40 | 41 | const columns = createColumns(); 42 | return ( 43 | <> 44 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/pages/jobs/view-job-page.tsx: -------------------------------------------------------------------------------- 1 | import { useGetJobView } from "@/domain/jobs/apis/get-job-view"; 2 | import { JobView } from "@/domain/jobs/components/job-view"; 3 | import { jobsRoute } from "@/router"; 4 | import { useParams } from "@tanstack/react-router"; 5 | import { Helmet } from "react-helmet"; 6 | 7 | export function JobViewPage() { 8 | const queryParams = useParams({ 9 | from: `${jobsRoute.fullPath}/$jobId`, 10 | }); 11 | const jobId = queryParams.jobId; 12 | const { data: jobData } = useGetJobView(jobId); 13 | const jobNumberTitle = jobId ? ` - ${jobId}` : ""; 14 | 15 | return ( 16 |
17 | 18 | Job {jobNumberTitle} 19 | 20 | 21 | {jobData && } 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { Notification } from "@/components/notifications"; 2 | import AuthLayout from "@/layouts/auth-layout"; 3 | import { ReactQueryDevtools, TanStackRouterDevtools } from "@/lib/dev-tools"; 4 | import { siteConfig } from "@/lib/site-config"; 5 | import { IndexPage } from "@/pages/index"; 6 | import { JobsWorklistPage } from "@/pages/jobs"; 7 | import { cn } from "@/utils"; 8 | import { 9 | Outlet, 10 | RoutePaths, 11 | createRootRoute, 12 | createRoute, 13 | createRouter, 14 | } from "@tanstack/react-router"; 15 | import { Helmet } from "react-helmet"; 16 | import { z } from "zod"; 17 | import { JobViewPage } from "./pages/jobs/view-job-page"; 18 | import { isProdEnv } from "./utils"; 19 | 20 | const appRoute = createRootRoute({ 21 | component: () => { 22 | return ( 23 | <> 24 | 28 | {/* 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 46 | */} 47 | 48 | 49 |
55 | 56 | 57 |
58 | 64 | 67 |
68 |
69 | 78 |
79 | 82 |
83 |
84 |
85 | 86 | ); 87 | }, 88 | }); 89 | 90 | export const rootRoute = createRoute({ 91 | getParentRoute: () => appRoute, 92 | path: "/", 93 | // component: IndexPage, 94 | loader: () => { 95 | router.navigate({ 96 | to: "/tasktower", 97 | }); 98 | }, 99 | }); 100 | 101 | const authLayout = createRoute({ 102 | getParentRoute: () => appRoute, 103 | path: "/tasktower", 104 | component: AuthLayout, 105 | }); 106 | 107 | export const dashboardRoute = createRoute({ 108 | getParentRoute: () => authLayout, 109 | path: "/", 110 | component: IndexPage, 111 | }); 112 | 113 | export const jobsRoute = createRoute({ 114 | getParentRoute: () => authLayout, 115 | path: "/jobs", 116 | component: () => { 117 | return ; 118 | }, 119 | }); 120 | 121 | const jobWorklistRoute = createRoute({ 122 | getParentRoute: () => jobsRoute, 123 | path: "/", 124 | component: JobsWorklistPage, 125 | }); 126 | 127 | const jobRoute = createRoute({ 128 | getParentRoute: () => jobsRoute, 129 | path: "$jobId", 130 | parseParams: (params) => ({ 131 | jobId: z.string().uuid().parse(params.jobId), 132 | }), 133 | component: JobViewPage, 134 | }); 135 | 136 | const routeTree = appRoute.addChildren([ 137 | authLayout.addChildren([ 138 | rootRoute, 139 | dashboardRoute, 140 | jobsRoute.addChildren([jobWorklistRoute, jobRoute]), 141 | ]), 142 | ]); 143 | 144 | // Create the router using your route tree 145 | export const router = createRouter({ routeTree }); 146 | 147 | // Register your router for maximum type safety 148 | declare module "@tanstack/react-router" { 149 | interface Register { 150 | router: typeof router; 151 | } 152 | } 153 | 154 | // export type AllRoutesPaths = RegisteredRouter["routePaths"]; 155 | export type AllRoutesPaths = RoutePaths; 156 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/types/apis.ts: -------------------------------------------------------------------------------- 1 | import { SortingState } from "@tanstack/react-table"; 2 | export interface PagedResponse { 3 | pagination: Pagination; 4 | data: T[]; 5 | } 6 | 7 | export interface Pagination { 8 | currentEndIndex: number; 9 | currentPageSize: number; 10 | currentStartIndex: number; 11 | hasNext: boolean; 12 | hasPrevious: boolean; 13 | pageNumber: number; 14 | pageSize: number; 15 | totalCount: number; 16 | totalPages: number; 17 | } 18 | 19 | export interface QueryParams { 20 | pageNumber?: number; 21 | pageSize?: number; 22 | filters?: string; 23 | sortOrder?: SortingState; 24 | } 25 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/utils/dates/date.extensions.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Date { 3 | toDateOnly(): string; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/utils/dates/index.ts: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | 3 | export function toDateOnly(date: Date | undefined) { 4 | return date !== undefined ? format(date, "yyyy-MM-dd") : undefined; 5 | } 6 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function getEnv() { 5 | const env = window.ASPNETCORE_ENVIRONMENT; 6 | return env === "{{ASPNETCORE_ENVIRONMENT}}" ? "Standalone" : env; 7 | } 8 | 9 | export function isStandaloneEnv() { 10 | return getEnv() === "Standalone"; 11 | } 12 | 13 | export function isProdEnv() { 14 | return getEnv() === "Production"; 15 | } 16 | 17 | export function cn(...inputs: ClassValue[]) { 18 | return twMerge(clsx(inputs)); 19 | } 20 | 21 | export { toDateOnly } from "./dates"; 22 | export { caseInsensitiveEquals } from "./strings"; 23 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/utils/strings.ts: -------------------------------------------------------------------------------- 1 | export function caseInsensitiveEquals( 2 | a: string | undefined | null, 3 | b: string | undefined | null 4 | ) { 5 | return typeof a === "string" && typeof b === "string" 6 | ? a.localeCompare(b, undefined, { sensitivity: "accent" }) === 0 7 | : a === b; 8 | } 9 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const defaultTheme = require('tailwindcss/defaultTheme') 3 | const { colors: defaultColors } = require('tailwindcss/defaultTheme') 4 | const {nextui} = require("@nextui-org/react"); 5 | 6 | export default { 7 | content: [ 8 | "./index.html", 9 | "./src/**/*.{js,ts,jsx,tsx}", 10 | "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}", 11 | ], 12 | theme: { 13 | extend: { 14 | fontFamily: { 15 | sans: ['Lexend', 'Inter', ...defaultTheme.fontFamily.sans], 16 | display: ['Lexend', ...defaultTheme.fontFamily.sans], 17 | }, 18 | colors: { 19 | ...defaultColors 20 | }, 21 | }, 22 | }, 23 | darkMode: "class", 24 | plugins: [nextui(),require('tailwindcss-debug-screens')], 25 | } 26 | 27 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | 21 | /* Linting */ 22 | "strict": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noFallthroughCasesInSwitch": true 26 | }, 27 | "include": ["src", 28 | "utils/dates/date.extensions.d.ts"], 29 | "references": [{ "path": "./tsconfig.node.json" }] 30 | } 31 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /TaskTower/React/task-tower-ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react-swc"; 2 | import path from "path"; 3 | import { defineConfig } from "vite"; 4 | import { viteStaticCopy } from "vite-plugin-static-copy"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | server: { 9 | port: 4264, 10 | }, 11 | build: { 12 | outDir: "../../WebApp", 13 | }, 14 | resolve: { 15 | alias: { 16 | "@": path.resolve(__dirname, "./src"), 17 | }, 18 | }, 19 | plugins: [ 20 | react(), 21 | viteStaticCopy({ 22 | targets: [ 23 | { 24 | src: "public/**/*", 25 | dest: "assets", 26 | }, 27 | ], 28 | }), 29 | ], 30 | }); 31 | -------------------------------------------------------------------------------- /TaskTower/Resources/BasePaginationParameters.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Resources; 2 | 3 | public abstract class BasePaginationParameters 4 | { 5 | internal virtual int MaxPageSize { get; } = 5000; 6 | internal virtual int DefaultPageSize { get; set; } = 10; 7 | 8 | public virtual int PageNumber { get; set; } = 1; 9 | 10 | public int PageSize 11 | { 12 | get 13 | { 14 | return DefaultPageSize; 15 | } 16 | set 17 | { 18 | DefaultPageSize = value > MaxPageSize ? MaxPageSize : value; 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /TaskTower/Resources/PagedList.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Resources; 2 | 3 | 4 | public sealed class PagedList : List 5 | { 6 | public int PageNumber { get; private set; } 7 | public int TotalPages { get; private set; } 8 | public int PageSize { get; private set; } 9 | public int CurrentPageSize { get; set; } 10 | public int CurrentStartIndex { get; set; } 11 | public int CurrentEndIndex { get; set; } 12 | public int TotalCount { get; private set; } 13 | 14 | public bool HasPrevious => PageNumber > 1; 15 | public bool HasNext => PageNumber < TotalPages; 16 | 17 | public PagedList(List items, int totalCount, int pageNumber, int pageSize) 18 | { 19 | TotalCount = totalCount; 20 | PageSize = pageSize; 21 | PageNumber = pageNumber; 22 | CurrentPageSize = items.Count; 23 | CurrentStartIndex = totalCount == 0 ? 0 : ((pageNumber - 1) * pageSize) + 1; 24 | CurrentEndIndex = totalCount == 0 ? 0 : CurrentStartIndex + CurrentPageSize - 1; 25 | TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize); 26 | 27 | AddRange(items); 28 | } 29 | } -------------------------------------------------------------------------------- /TaskTower/TaskTower.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | 9 | TaskTower 10 | TaskTower;BackgroundJob;Hangfire 11 | 0.1.0 12 | Paul DeVito 13 | Simple, reliable and efficient background jobs in .NET - an alternative to HangFire 14 | Simple, reliable and efficient background jobs in .NET - an alternative to HangFire 15 | https://github.com/pdevito3/TaskTower 16 | git 17 | README.md 18 | true 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | all 37 | runtime; build; native; contentfiles; analyzers; buildtransitive 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /TaskTower/TaskTowerConstants.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower; 2 | 3 | public class TaskTowerConstants 4 | { 5 | public const string UiEmbeddedFileNamespace = "TaskTower.WebApp"; 6 | public const string TaskTowerUiRoot = "tasktower"; 7 | public static class Notifications 8 | { 9 | public const string JobAvailable = "job_available"; 10 | } 11 | public static class Configuration 12 | { 13 | public const int MinimumWaitIntervalMilliseconds = 500; 14 | } 15 | } -------------------------------------------------------------------------------- /TaskTower/Utils/BackoffCalculator.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Utils; 2 | 3 | public static class BackoffCalculator 4 | { 5 | // CalculateBackoff calculates the number of seconds to back off before the next retry 6 | // this formula is ported from neoq who got it from Sidekiq because it is good. 7 | public static DateTimeOffset CalculateBackoff(int retryCount) 8 | { 9 | const int backoffExponent = 4; 10 | const int maxInt = 30; 11 | var rand = new Random(); 12 | 13 | var p = Convert.ToInt32(Math.Round(Math.Pow(retryCount, backoffExponent))); 14 | var additionalSeconds = p + 15 + rand.Next(maxInt) * retryCount + 1; 15 | return DateTimeOffset.UtcNow.AddSeconds(additionalSeconds); 16 | } 17 | } -------------------------------------------------------------------------------- /TaskTower/Utils/NotificationHelper.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Utils; 2 | 3 | 4 | public static class NotificationHelper 5 | { 6 | public static string CreatePayload(string queue, Guid jobId) => $"Queue: {queue}, ID: {jobId}"; 7 | 8 | public static (string Queue, Guid JobId) ParsePayload(string payload) 9 | { 10 | var parts = payload.Split(new[] { ", ID: " }, StringSplitOptions.None); 11 | var queuePart = parts[0].Substring("Queue: ".Length); 12 | var idPart = parts.Length > 1 ? Guid.Parse(parts[1]) : Guid.Empty; 13 | 14 | return (queuePart, idPart); 15 | } 16 | } -------------------------------------------------------------------------------- /TaskTower/Utils/StringUtility.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTower.Utils; 2 | 3 | using System.Text.RegularExpressions; 4 | 5 | public static class StringUtility 6 | { 7 | public static string StripNonAlphanum(string input) 8 | { 9 | var re = new Regex(@"[^a-zA-Z0-9_]"); 10 | return re.Replace(input, ""); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /TaskTower/WebApp/assets/index-Ds5yF1gS.js: -------------------------------------------------------------------------------- 1 | var e=function(){return null};export{e as ReactQueryDevtools}; 2 | -------------------------------------------------------------------------------- /TaskTower/WebApp/assets/logo-B4WEyPIj.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /TaskTower/WebApp/assets/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /TaskTower/WebApp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | Task Tower 12 | 13 | 14 | 15 | 16 |
17 | 18 | 21 | 22 | -------------------------------------------------------------------------------- /TaskTower/WebApp/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /TaskTowerSandbox/LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Task Tower software is an open-source software that is multi-licensed under the terms of the licenses listed in this file. Recipients may choose the terms under which they are want to use or distribute the software, when all the preconditions of a chosen license are satisfied. 4 | 5 | ## LGPL v3 License 6 | 7 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. 10 | 11 | Please see License-lgpl.md file for details. 12 | 13 | ## Commercial License 14 | 15 | Once Task Tower reaches at least a stable v1, usage of Task Tower at or above v1 will be subject to the purchase of a subscription for a subset of users that meet a to be determined set of criteria. All usage of Task Tower in pre-v1 versions will only be subject to the LGPL v3 License above. -------------------------------------------------------------------------------- /TaskTowerSandbox/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:25876", 8 | "sslPort": 44385 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5130", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7257;http://localhost:5130", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "https2": { 33 | "commandName": "Project", 34 | "dotnetRunMessages": true, 35 | "launchBrowser": true, 36 | "launchUrl": "swagger", 37 | "applicationUrl": "https://localhost:7257;http://localhost:5131", 38 | "environmentVariables": { 39 | "ASPNETCORE_ENVIRONMENT": "Development" 40 | } 41 | }, 42 | "IIS Express": { 43 | "commandName": "IISExpress", 44 | "launchBrowser": true, 45 | "launchUrl": "swagger", 46 | "environmentVariables": { 47 | "ASPNETCORE_ENVIRONMENT": "Development" 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /TaskTowerSandbox/Sandboxing/DeathPipeline.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTowerSandbox.Sandboxing; 2 | 3 | using TaskTower.Interception; 4 | using TaskTower.Middleware; 5 | using TaskTower.Processing; 6 | 7 | public class SlackSaysDeathInterceptor : JobInterceptor 8 | { 9 | private readonly IServiceProvider _serviceProvider; 10 | 11 | public SlackSaysDeathInterceptor(IServiceProvider serviceProvider) : base(serviceProvider) 12 | { 13 | _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); 14 | } 15 | 16 | public override JobServiceProvider Intercept(JobInterceptorContext interceptorContext) 17 | { 18 | var jobId = interceptorContext.Job.Id; 19 | var errorDetails = interceptorContext.ErrorDetails; 20 | var fakeSlackService = _serviceProvider.GetRequiredService(); 21 | 22 | fakeSlackService.SendMessage("death", $""" 23 | Job {jobId} has died with error: {errorDetails?.Message} at {errorDetails?.OccurredAt}. Here's the details 24 | 25 | {errorDetails?.Details} 26 | """); 27 | 28 | return new JobServiceProvider(_serviceProvider); 29 | } 30 | } 31 | 32 | public class TeamsSaysDeathInterceptor : JobInterceptor 33 | { 34 | private readonly IServiceProvider _serviceProvider; 35 | 36 | public TeamsSaysDeathInterceptor(IServiceProvider serviceProvider) : base(serviceProvider) 37 | { 38 | _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); 39 | } 40 | 41 | public override JobServiceProvider Intercept(JobInterceptorContext interceptorContext) 42 | { 43 | var jobId = interceptorContext.Job.Id; 44 | var errorDetails = interceptorContext.ErrorDetails; 45 | var fakeTeamsService = _serviceProvider.GetRequiredService(); 46 | 47 | fakeTeamsService.SendMessage("death", $"""" 48 | Job {jobId} has died with error: {errorDetails?.Message} at {errorDetails?.OccurredAt} 49 | """"); 50 | 51 | return new JobServiceProvider(_serviceProvider); 52 | } 53 | } -------------------------------------------------------------------------------- /TaskTowerSandbox/Sandboxing/DoAThing.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTowerSandbox.Sandboxing; 2 | 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | using Serilog; 6 | using TaskTower.Database; 7 | using TaskTower.Domain.JobStatuses; 8 | using TaskTower.Interception; 9 | 10 | public class DoAThing 11 | { 12 | public sealed record Command(string Data); 13 | 14 | public async Task Handle(Command request) 15 | { 16 | // await Task.Delay(1000); 17 | Log.Information("Handled DoAThing with data: {Data}", request.Data); 18 | } 19 | } 20 | 21 | 22 | 23 | public class DoAContextualizerThing(IJobContextAccessor jobContextAccessor) 24 | { 25 | public sealed record Command(string? User) : IJobWithUserContext; 26 | 27 | public async Task Handle(Command request) 28 | { 29 | Log.Information("Handled DoAContextualizerThing with a user from the param as: {RequestUser} and from the context as: {UserContextUser} with an Id of {Id} with this noteworth thing: {Note}", 30 | request.User, 31 | jobContextAccessor?.UserContext?.User, 32 | jobContextAccessor?.UserContext?.UserId, 33 | jobContextAccessor?.UserContext?.NullableNote); 34 | 35 | } 36 | } 37 | 38 | public class DoAnInjectableThing(IDummyLogger logger, PokeApiService pokeApiService, ITaskTowerRunnerContext context) 39 | { 40 | public sealed record Command(string Data); 41 | 42 | public async Task Handle(Command request) 43 | { 44 | // await Task.Delay(1000); 45 | // var result = context.RunHistories.FirstOrDefault(x => x.Status == JobStatus.Completed()); 46 | // Log.Information("I just read a RunHistory with an ID of {TempId}", result?.Id); 47 | 48 | Log.Information("I am running a job with an Id of {Id} that I got from context", context.JobId); 49 | 50 | var pokemon = await pokeApiService.GetRandomPokemonAsync(); 51 | Log.Information("I just read a Pokemon with an ID of {PokemonId} and content of {Name}", pokemon.Item1, pokemon.Item2); 52 | 53 | logger.Log($"Handled DoAnInjectableThing with data: {request.Data}"); 54 | } 55 | } 56 | 57 | public class DoADefaultThing 58 | { 59 | public sealed record Command(string Data); 60 | 61 | public async Task Handle(Command request) 62 | { 63 | // await Task.Delay(1000); 64 | Log.Information("Handled DoAThing with data: {Data}", request.Data); 65 | } 66 | } 67 | 68 | public class DoACriticalThing 69 | { 70 | public sealed record Command(string Data); 71 | 72 | public async Task Handle(Command request) 73 | { 74 | // await Task.Delay(1000); 75 | Log.Information("Handled DoAThing with data: {Data}", request.Data); 76 | } 77 | } 78 | 79 | public class DoALowThing 80 | { 81 | public sealed record Command(string Data); 82 | 83 | public async Task Handle(Command request) 84 | { 85 | // await Task.Delay(1000); 86 | Log.Information("Handled DoAThing with data: {Data}", request.Data); 87 | } 88 | } 89 | 90 | public class DoAPossiblyFailingThing 91 | { 92 | public sealed record Command(string Data); 93 | 94 | public async Task Handle(Command request) 95 | { 96 | var success = new Random().Next(0, 100) < 70; 97 | if (!success) 98 | { 99 | throw new Exception("Failed"); 100 | } 101 | 102 | Log.Information("Handled DoAPossiblyFailingThing with data: {Data}", request.Data); 103 | if (request.Data == "fail") 104 | { 105 | throw new Exception("Failed"); 106 | } 107 | } 108 | } 109 | 110 | public class DoASynchronousThing 111 | { 112 | public sealed record Command(string Data); 113 | 114 | public void Handle(Command command) 115 | { 116 | // Simulate work 117 | Console.WriteLine($"Handled DoASynchronousThing with data: {command.Data}"); 118 | } 119 | } 120 | 121 | public class DoASlowThing 122 | { 123 | public sealed record Command(string Data); 124 | 125 | public async Task Handle(Command request) 126 | { 127 | await Task.Delay(15000); 128 | Log.Information("Handled DoALongDelayThing with data: {Data}", request.Data); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /TaskTowerSandbox/Sandboxing/DummyLogger.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTowerSandbox.Sandboxing; 2 | 3 | public interface IDummyLogger 4 | { 5 | void Log(string message); 6 | } 7 | 8 | public class DummyLogger : IDummyLogger 9 | { 10 | public void Log(string message) 11 | { 12 | Console.WriteLine(message); 13 | Console.WriteLine("this was injected"); 14 | } 15 | } -------------------------------------------------------------------------------- /TaskTowerSandbox/Sandboxing/FakeSlackService.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTowerSandbox.Sandboxing; 2 | 3 | using Serilog; 4 | 5 | public class FakeSlackService() 6 | { 7 | public void SendMessage(string channel, string message) 8 | { 9 | Log.Information("SLACK: Sending message to the '{Channel}' channel: '{Message}'", channel, message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /TaskTowerSandbox/Sandboxing/FakeTeamsService.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTowerSandbox.Sandboxing; 2 | 3 | using Serilog; 4 | 5 | public class FakeTeamsService() 6 | { 7 | public void SendMessage(string channel, string message) 8 | { 9 | Log.Information("TEAMS: Sending message to the '{Channel}' channel: '{Message}'", channel, message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /TaskTowerSandbox/Sandboxing/PokeApiService.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTowerSandbox.Sandboxing; 2 | 3 | using System.Text.Json; 4 | 5 | public record Pokemon(string name); 6 | public class PokeApiService(IHttpClientFactory httpClientFactory) 7 | { 8 | private readonly HttpClient _httpClient = httpClientFactory.CreateClient("PokeAPI"); 9 | 10 | public async Task<(int, string)> GetRandomPokemonAsync() 11 | { 12 | var randomPokemonId = new Random().Next(1, 899); 13 | var response = await _httpClient.GetAsync($"pokemon/{randomPokemonId}"); 14 | 15 | if (response.IsSuccessStatusCode) 16 | { 17 | var content = await response.Content.ReadAsStringAsync(); 18 | var pokemon = JsonSerializer.Deserialize(content); 19 | return (randomPokemonId, pokemon.name); 20 | } 21 | 22 | return (randomPokemonId, "Pokemon not found"); 23 | } 24 | } -------------------------------------------------------------------------------- /TaskTowerSandbox/Sandboxing/UserPipeline.cs: -------------------------------------------------------------------------------- 1 | namespace TaskTowerSandbox.Sandboxing; 2 | 3 | using TaskTower.Interception; 4 | using TaskTower.Middleware; 5 | using TaskTower.Processing; 6 | 7 | public class CurrentUserAssignmentContext : IJobContextualizer 8 | { 9 | public void EnrichContext(JobContext context) 10 | { 11 | var argue = context.Job.ContextParameters.FirstOrDefault(x => x is IJobWithUserContext); 12 | if (argue == null) 13 | throw new Exception($"This job does not implement the {nameof(IJobWithUserContext)} interface"); 14 | 15 | var jobParameters = argue as IJobWithUserContext; 16 | var user = jobParameters?.User; 17 | 18 | if(user == null) 19 | throw new Exception($"A User could not be established"); 20 | 21 | context.SetJobContextParameter("User", user); 22 | } 23 | } 24 | 25 | public class JobUserAssignmentContext : IJobContextualizer 26 | { 27 | public void EnrichContext(JobContext context) 28 | { 29 | var user = "job-user-346f9812-16da-4a72-9db2-f066661d6593"; 30 | var isNull = new Random().Next(0, 2) == 0; 31 | // Guid? userId = isNull 32 | // ? null 33 | // : Guid.Parse("346f9812-16da-4a72-9db2-f066661d6593"); 34 | Guid userId = Guid.Parse("346f9812-16da-4a72-9db2-f066661d6593"); 35 | context.SetJobContextParameter("User", user); 36 | context.SetJobContextParameter("UserId", userId); 37 | } 38 | } 39 | 40 | public class JobWithUserContextInterceptor : JobInterceptor 41 | { 42 | private readonly IServiceProvider _serviceProvider; 43 | 44 | public JobWithUserContextInterceptor(IServiceProvider serviceProvider) : base(serviceProvider) 45 | { 46 | _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); 47 | } 48 | 49 | public override JobServiceProvider Intercept(JobInterceptorContext interceptorContext) 50 | { 51 | var user = interceptorContext.GetContextParameter("User"); 52 | var userId = interceptorContext.GetContextParameter("UserId"); 53 | 54 | if (user == null) 55 | { 56 | return base.Intercept(interceptorContext); 57 | } 58 | 59 | var userContextForJob = _serviceProvider.GetRequiredService(); 60 | userContextForJob.UserContext = new JobWithUserContext {User = user, UserId = userId}; 61 | 62 | return new JobServiceProvider(_serviceProvider); 63 | } 64 | } 65 | 66 | public interface IJobWithUserContext 67 | { 68 | public string? User { get; init; } 69 | } 70 | public class JobWithUserContext : IJobWithUserContext 71 | { 72 | public string? User { get; init; } 73 | public Guid UserId { get; init; } 74 | public string? NullableNote { get; init; } 75 | } 76 | public interface IJobContextAccessor 77 | { 78 | JobWithUserContext? UserContext { get; set; } 79 | } 80 | public class JobContextAccessor : IJobContextAccessor 81 | { 82 | public JobWithUserContext? UserContext { get; set; } 83 | } 84 | -------------------------------------------------------------------------------- /TaskTowerSandbox/TaskTowerSandbox.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /TaskTowerSandbox/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /TaskTowerSandbox/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | hello-pg-pub-sub-db: 5 | image: postgres 6 | restart: always 7 | ports: 8 | - '51554:5432' 9 | environment: 10 | POSTGRES_USER: postgres 11 | POSTGRES_PASSWORD: postgres 12 | POSTGRES_DB: dev_hello_pg_pub_sub 13 | volumes: 14 | - hello-pg-pub-sub-data:/var/lib/postgresql/data 15 | 16 | task-tower-sandbox-db: 17 | image: postgres 18 | restart: always 19 | ports: 20 | - '41444:5432' 21 | environment: 22 | POSTGRES_USER: postgres 23 | POSTGRES_PASSWORD: postgres 24 | POSTGRES_DB: dev_hello_task_tower_sandbox 25 | volumes: 26 | - task-tower-sandbox-data:/var/lib/postgresql/data 27 | 28 | volumes: 29 | # compose volumes marker - do not delete 30 | recipemanagement-data: 31 | hello-pg-pub-sub-data: 32 | task-tower-sandbox-data: 33 | -------------------------------------------------------------------------------- /hello-tasktower.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HelloPgPubSub", "HelloPgPubSub\HelloPgPubSub.csproj", "{3BC28877-9685-49D8-8E35-FF9C0A8E19A5}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskTowerSandbox", "TaskTowerSandbox\TaskTowerSandbox.csproj", "{DF00BFCD-1C54-423D-B41F-7AAD3A99C356}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskTower", "TaskTower\TaskTower.csproj", "{8D19E80E-DE7E-4CD0-B89A-168277DA70C5}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(SolutionProperties) = preSolution 18 | HideSolutionNode = FALSE 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {3BC28877-9685-49D8-8E35-FF9C0A8E19A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {3BC28877-9685-49D8-8E35-FF9C0A8E19A5}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {3BC28877-9685-49D8-8E35-FF9C0A8E19A5}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {3BC28877-9685-49D8-8E35-FF9C0A8E19A5}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {DF00BFCD-1C54-423D-B41F-7AAD3A99C356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {DF00BFCD-1C54-423D-B41F-7AAD3A99C356}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {DF00BFCD-1C54-423D-B41F-7AAD3A99C356}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {DF00BFCD-1C54-423D-B41F-7AAD3A99C356}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {8D19E80E-DE7E-4CD0-B89A-168277DA70C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {8D19E80E-DE7E-4CD0-B89A-168277DA70C5}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {8D19E80E-DE7E-4CD0-B89A-168277DA70C5}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {8D19E80E-DE7E-4CD0-B89A-168277DA70C5}.Release|Any CPU.Build.0 = Release|Any CPU 33 | EndGlobalSection 34 | GlobalSection(NestedProjects) = preSolution 35 | EndGlobalSection 36 | EndGlobal 37 | --------------------------------------------------------------------------------