Compare commits
17 Commits
v1.4.0
...
feature/FA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4264051d11 | ||
|
|
7ccc3ade9e | ||
|
|
9bc39c3abf | ||
|
|
bdb863a032 | ||
|
|
7c3df7ab11 | ||
|
|
2e4e2c26aa | ||
|
|
1057e1bcd4 | ||
|
|
1fda5ad440 | ||
|
|
2c14ab4936 | ||
|
|
433f038051 | ||
|
|
3c835d9cc3 | ||
|
|
9577aa996a | ||
|
|
c25f59a4b4 | ||
|
|
be1ebbea39 | ||
| 67521d6530 | |||
|
|
a6242fdb2a | ||
|
|
3c8c8c8707 |
@@ -31,6 +31,9 @@ jobs:
|
||||
- name: usernoveldata-service
|
||||
project: FictionArchive.Service.UserNovelDataService
|
||||
subgraph: UserNovelData
|
||||
- name: reporting-service
|
||||
project: FictionArchive.Service.ReportingService
|
||||
subgraph: Reporting
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -119,6 +122,12 @@ jobs:
|
||||
name: usernoveldata-service-subgraph
|
||||
path: subgraphs/usernoveldata
|
||||
|
||||
- name: Download Reporting Service subgraph
|
||||
uses: christopherhx/gitea-download-artifact@v4
|
||||
with:
|
||||
name: reporting-service-subgraph
|
||||
path: subgraphs/reporting
|
||||
|
||||
- name: Configure subgraph URLs for Docker
|
||||
run: |
|
||||
for fsp in subgraphs/*/*.fsp; do
|
||||
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
dockerfile: FictionArchive.Service.FileService/Dockerfile
|
||||
- name: scheduler-service
|
||||
dockerfile: FictionArchive.Service.SchedulerService/Dockerfile
|
||||
- name: authentication-service
|
||||
dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile
|
||||
- name: usernoveldata-service
|
||||
dockerfile: FictionArchive.Service.UserNovelDataService/Dockerfile
|
||||
- name: reporting-service
|
||||
dockerfile: FictionArchive.Service.ReportingService/Dockerfile
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
9
FictionArchive.Common/Enums/JobStatus.cs
Normal file
9
FictionArchive.Common/Enums/JobStatus.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace FictionArchive.Common.Enums;
|
||||
|
||||
public enum JobStatus
|
||||
{
|
||||
Failed = -1,
|
||||
Pending = 0,
|
||||
InProgress = 1,
|
||||
Completed = 2
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Amazon.S3.Model;
|
||||
using FictionArchive.Common.Enums;
|
||||
using FictionArchive.Service.FileService.Models;
|
||||
using FictionArchive.Service.Shared.Contracts.Events;
|
||||
using FictionArchive.Service.Shared.Extensions;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -35,10 +36,15 @@ public class FileUploadRequestCreatedConsumer : IConsumer<IFileUploadRequestCrea
|
||||
{
|
||||
var message = context.Message;
|
||||
|
||||
await _publishEndpoint.ReportJobStatus(
|
||||
message.RequestId, "FileUpload", $"Upload {message.FilePath}",
|
||||
JobStatus.InProgress, parentJobId: message.ImportId);
|
||||
|
||||
var putObjectRequest = new PutObjectRequest
|
||||
{
|
||||
BucketName = _s3Configuration.Bucket,
|
||||
Key = message.FilePath
|
||||
Key = message.FilePath,
|
||||
UseChunkEncoding = false
|
||||
};
|
||||
|
||||
using var memoryStream = new MemoryStream(message.FileData);
|
||||
@@ -57,6 +63,11 @@ public class FileUploadRequestCreatedConsumer : IConsumer<IFileUploadRequestCrea
|
||||
Status: RequestStatus.Failed,
|
||||
FileAccessUrl: null,
|
||||
ErrorMessage: "An error occurred while uploading file to S3."));
|
||||
|
||||
await _publishEndpoint.ReportJobStatus(
|
||||
message.RequestId, "FileUpload", $"Upload {message.FilePath}",
|
||||
JobStatus.Failed, parentJobId: message.ImportId,
|
||||
errorMessage: "An error occurred while uploading file to S3.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,5 +82,10 @@ public class FileUploadRequestCreatedConsumer : IConsumer<IFileUploadRequestCrea
|
||||
Status: RequestStatus.Success,
|
||||
FileAccessUrl: fileAccessUrl,
|
||||
ErrorMessage: null));
|
||||
|
||||
await _publishEndpoint.ReportJobStatus(
|
||||
message.RequestId, "FileUpload", $"Upload {message.FilePath}",
|
||||
JobStatus.Completed, parentJobId: message.ImportId,
|
||||
metadata: new Dictionary<string, string> { ["FileAccessUrl"] = fileAccessUrl });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ public class NovelImportSagaTests
|
||||
|
||||
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
|
||||
(await sagaHarness.Exists(importId, x => x.Importing)).HasValue.Should().BeTrue();
|
||||
|
||||
(await harness.Published.Any<IJobStatusUpdate>(x =>
|
||||
x.Context.Message.JobId == importId &&
|
||||
x.Context.Message.Status == JobStatus.InProgress &&
|
||||
x.Context.Message.JobType == "NovelImport")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -38,13 +43,18 @@ public class NovelImportSagaTests
|
||||
|
||||
var importId = Guid.NewGuid();
|
||||
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
|
||||
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 0));
|
||||
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 0, false));
|
||||
|
||||
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
|
||||
(await sagaHarness.Exists(importId, x => x.Completed)).HasValue.Should().BeTrue();
|
||||
|
||||
(await harness.Published.Any<INovelImportCompleted>(x =>
|
||||
x.Context.Message.ImportId == importId && x.Context.Message.Success)).Should().BeTrue();
|
||||
|
||||
(await harness.Published.Any<IJobStatusUpdate>(x =>
|
||||
x.Context.Message.JobId == importId &&
|
||||
x.Context.Message.Status == JobStatus.Completed &&
|
||||
x.Context.Message.JobType == "NovelImport")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -56,7 +66,7 @@ public class NovelImportSagaTests
|
||||
|
||||
var importId = Guid.NewGuid();
|
||||
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
|
||||
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 2));
|
||||
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 2, false));
|
||||
|
||||
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
|
||||
(await sagaHarness.Exists(importId, x => x.Processing)).HasValue.Should().BeTrue();
|
||||
@@ -71,7 +81,7 @@ public class NovelImportSagaTests
|
||||
|
||||
var importId = Guid.NewGuid();
|
||||
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
|
||||
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 2));
|
||||
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 2, false));
|
||||
await harness.Bus.Publish<IChapterPullCompleted>(new ChapterPullCompleted(importId, 1, 1));
|
||||
await harness.Bus.Publish<IChapterPullCompleted>(new ChapterPullCompleted(importId, 2, 0));
|
||||
await harness.Bus.Publish<IFileUploadRequestStatusUpdate>(new FileUploadRequestStatusUpdate(
|
||||
@@ -79,6 +89,95 @@ public class NovelImportSagaTests
|
||||
|
||||
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
|
||||
(await sagaHarness.Exists(importId, x => x.Completed)).HasValue.Should().BeTrue();
|
||||
|
||||
(await harness.Published.Any<IJobStatusUpdate>(x =>
|
||||
x.Context.Message.JobId == importId &&
|
||||
x.Context.Message.Status == JobStatus.Completed &&
|
||||
x.Context.Message.JobType == "NovelImport")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_transition_to_processing_when_cover_image_queued_with_no_chapters()
|
||||
{
|
||||
await using var provider = CreateTestProvider();
|
||||
var harness = provider.GetRequiredService<ITestHarness>();
|
||||
await harness.Start();
|
||||
|
||||
var importId = Guid.NewGuid();
|
||||
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
|
||||
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 0, true));
|
||||
|
||||
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
|
||||
(await sagaHarness.Exists(importId, x => x.Processing)).HasValue.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_complete_when_chapters_pulled_images_uploaded_and_cover_uploaded()
|
||||
{
|
||||
await using var provider = CreateTestProvider();
|
||||
var harness = provider.GetRequiredService<ITestHarness>();
|
||||
await harness.Start();
|
||||
|
||||
var importId = Guid.NewGuid();
|
||||
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
|
||||
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 1, true));
|
||||
await harness.Bus.Publish<IChapterPullCompleted>(new ChapterPullCompleted(importId, 1, 0));
|
||||
|
||||
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
|
||||
|
||||
// Should still be processing - cover image not yet uploaded
|
||||
(await sagaHarness.Exists(importId, x => x.Processing)).HasValue.Should().BeTrue();
|
||||
|
||||
// Upload cover image
|
||||
await harness.Bus.Publish<IFileUploadRequestStatusUpdate>(new FileUploadRequestStatusUpdate(
|
||||
importId, Guid.NewGuid(), RequestStatus.Success, "https://cdn.example.com/cover.jpg", null));
|
||||
|
||||
(await sagaHarness.Exists(importId, x => x.Completed)).HasValue.Should().BeTrue();
|
||||
|
||||
(await harness.Published.Any<INovelImportCompleted>(x =>
|
||||
x.Context.Message.ImportId == importId && x.Context.Message.Success)).Should().BeTrue();
|
||||
|
||||
(await harness.Published.Any<IJobStatusUpdate>(x =>
|
||||
x.Context.Message.JobId == importId &&
|
||||
x.Context.Message.Status == JobStatus.Completed &&
|
||||
x.Context.Message.JobType == "NovelImport")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_publish_failed_job_status_on_chapter_pull_fault()
|
||||
{
|
||||
await using var provider = CreateTestProvider();
|
||||
var harness = provider.GetRequiredService<ITestHarness>();
|
||||
await harness.Start();
|
||||
|
||||
var importId = Guid.NewGuid();
|
||||
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
|
||||
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 1, false));
|
||||
|
||||
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
|
||||
(await sagaHarness.Exists(importId, x => x.Processing)).HasValue.Should().BeTrue();
|
||||
|
||||
await harness.Bus.Publish<Fault<IChapterPullRequested>>(new
|
||||
{
|
||||
Message = new ChapterPullRequested(importId, 1, 1, 1),
|
||||
Exceptions = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ExceptionType = typeof(Exception).FullName!,
|
||||
Message = "Chapter pull failed",
|
||||
StackTrace = "stack trace",
|
||||
InnerException = (object?)null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(await sagaHarness.Exists(importId, x => x.Failed)).HasValue.Should().BeTrue();
|
||||
|
||||
(await harness.Published.Any<IJobStatusUpdate>(x =>
|
||||
x.Context.Message.JobId == importId &&
|
||||
x.Context.Message.Status == JobStatus.Failed &&
|
||||
x.Context.Message.JobType == "NovelImport")).Should().BeTrue();
|
||||
}
|
||||
|
||||
private ServiceProvider CreateTestProvider()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using FictionArchive.Common.Enums;
|
||||
using FictionArchive.Service.NovelService.Services;
|
||||
using FictionArchive.Service.Shared.Contracts.Events;
|
||||
using FictionArchive.Service.Shared.Extensions;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -21,6 +23,11 @@ public class ChapterPullRequestedConsumer : IConsumer<IChapterPullRequested>
|
||||
public async Task Consume(ConsumeContext<IChapterPullRequested> context)
|
||||
{
|
||||
var message = context.Message;
|
||||
var chapterJobId = Guid.NewGuid();
|
||||
|
||||
await context.ReportJobStatus(
|
||||
chapterJobId, "ChapterPull", $"Pull chapter {message.ChapterOrder}",
|
||||
JobStatus.InProgress, parentJobId: message.ImportId);
|
||||
|
||||
var (chapter, imageCount) = await _novelUpdateService.PullChapterContents(
|
||||
message.ImportId,
|
||||
@@ -33,5 +40,10 @@ public class ChapterPullRequestedConsumer : IConsumer<IChapterPullRequested>
|
||||
chapter.Id,
|
||||
imageCount
|
||||
));
|
||||
|
||||
await context.ReportJobStatus(
|
||||
chapterJobId, "ChapterPull", $"Pull chapter {message.ChapterOrder}",
|
||||
JobStatus.Completed, parentJobId: message.ImportId,
|
||||
metadata: new Dictionary<string, string> { ["ChapterId"] = chapter.Id.ToString() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace FictionArchive.Service.NovelService.Contracts;
|
||||
|
||||
public record ImportNovelResult(Guid ImportId, string NovelUrl);
|
||||
@@ -16,7 +16,7 @@ public class Mutation
|
||||
{
|
||||
[Error<InvalidOperationException>]
|
||||
[Authorize]
|
||||
public async Task<NovelImportRequested> ImportNovel(string novelUrl, NovelUpdateService service)
|
||||
public async Task<ImportNovelResult> ImportNovel(string novelUrl, NovelUpdateService service)
|
||||
{
|
||||
return await service.QueueNovelImport(novelUrl);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using FictionArchive.Common.Enums;
|
||||
using FictionArchive.Service.Shared.Contracts.Events;
|
||||
using MassTransit;
|
||||
using NodaTime;
|
||||
@@ -49,6 +50,10 @@ public class NovelImportSaga : MassTransitStateMachine<NovelImportSagaState>
|
||||
ctx.Saga.StartedAt = _clock.GetCurrentInstant();
|
||||
})
|
||||
.TransitionTo(Importing)
|
||||
.PublishAsync(ctx => ctx.Init<IJobStatusUpdate>(new JobStatusUpdate(
|
||||
ctx.Saga.CorrelationId, null, "NovelImport",
|
||||
$"Import {ctx.Saga.NovelUrl}", JobStatus.InProgress,
|
||||
null, new Dictionary<string, string> { ["NovelUrl"] = ctx.Saga.NovelUrl })))
|
||||
);
|
||||
|
||||
During(Importing,
|
||||
@@ -57,9 +62,10 @@ public class NovelImportSaga : MassTransitStateMachine<NovelImportSagaState>
|
||||
{
|
||||
ctx.Saga.NovelId = ctx.Message.NovelId;
|
||||
ctx.Saga.ExpectedChapters = ctx.Message.ChaptersPendingPull;
|
||||
ctx.Saga.ExpectedImages += ctx.Message.CoverImageQueued ? 1 : 0;
|
||||
})
|
||||
.IfElse(
|
||||
ctx => ctx.Saga.ExpectedChapters == 0,
|
||||
ctx => ctx.Saga.ExpectedChapters == 0 && !ctx.Message.CoverImageQueued,
|
||||
thenBinder => thenBinder
|
||||
.Then(ctx => ctx.Saga.CompletedAt = _clock.GetCurrentInstant())
|
||||
.TransitionTo(Completed)
|
||||
@@ -67,7 +73,11 @@ public class NovelImportSaga : MassTransitStateMachine<NovelImportSagaState>
|
||||
ctx.Saga.CorrelationId,
|
||||
ctx.Saga.NovelId,
|
||||
true,
|
||||
null))),
|
||||
null)))
|
||||
.PublishAsync(ctx => ctx.Init<IJobStatusUpdate>(new JobStatusUpdate(
|
||||
ctx.Saga.CorrelationId, null, "NovelImport",
|
||||
$"Import {ctx.Saga.NovelUrl}", JobStatus.Completed,
|
||||
null, new Dictionary<string, string> { ["NovelId"] = ctx.Saga.NovelId.ToString() }))),
|
||||
elseBinder => elseBinder.TransitionTo(Processing)
|
||||
)
|
||||
);
|
||||
@@ -86,7 +96,11 @@ public class NovelImportSaga : MassTransitStateMachine<NovelImportSagaState>
|
||||
c.Saga.CorrelationId,
|
||||
c.Saga.NovelId,
|
||||
true,
|
||||
null)))),
|
||||
null)))
|
||||
.PublishAsync(c => c.Init<IJobStatusUpdate>(new JobStatusUpdate(
|
||||
c.Saga.CorrelationId, null, "NovelImport",
|
||||
$"Import {c.Saga.NovelUrl}", JobStatus.Completed,
|
||||
null, new Dictionary<string, string> { ["NovelId"] = c.Saga.NovelId.ToString() })))),
|
||||
|
||||
When(FileUploadStatusUpdate)
|
||||
.Then(ctx => ctx.Saga.CompletedImages++)
|
||||
@@ -97,7 +111,11 @@ public class NovelImportSaga : MassTransitStateMachine<NovelImportSagaState>
|
||||
c.Saga.CorrelationId,
|
||||
c.Saga.NovelId,
|
||||
true,
|
||||
null)))),
|
||||
null)))
|
||||
.PublishAsync(c => c.Init<IJobStatusUpdate>(new JobStatusUpdate(
|
||||
c.Saga.CorrelationId, null, "NovelImport",
|
||||
$"Import {c.Saga.NovelUrl}", JobStatus.Completed,
|
||||
null, new Dictionary<string, string> { ["NovelId"] = c.Saga.NovelId.ToString() })))),
|
||||
|
||||
When(ChapterPullFaulted)
|
||||
.Then(ctx =>
|
||||
@@ -110,7 +128,11 @@ public class NovelImportSaga : MassTransitStateMachine<NovelImportSagaState>
|
||||
ctx.Saga.CorrelationId,
|
||||
ctx.Saga.NovelId,
|
||||
false,
|
||||
ctx.Saga.ErrorMessage))),
|
||||
ctx.Saga.ErrorMessage)))
|
||||
.PublishAsync(ctx => ctx.Init<IJobStatusUpdate>(new JobStatusUpdate(
|
||||
ctx.Saga.CorrelationId, null, "NovelImport",
|
||||
$"Import {ctx.Saga.NovelUrl}", JobStatus.Failed,
|
||||
ctx.Saga.ErrorMessage, null))),
|
||||
|
||||
When(FileUploadFaulted)
|
||||
.Then(ctx =>
|
||||
@@ -124,6 +146,10 @@ public class NovelImportSaga : MassTransitStateMachine<NovelImportSagaState>
|
||||
ctx.Saga.NovelId,
|
||||
false,
|
||||
ctx.Saga.ErrorMessage)))
|
||||
.PublishAsync(ctx => ctx.Init<IJobStatusUpdate>(new JobStatusUpdate(
|
||||
ctx.Saga.CorrelationId, null, "NovelImport",
|
||||
$"Import {ctx.Saga.NovelUrl}", JobStatus.Failed,
|
||||
ctx.Saga.ErrorMessage, null)))
|
||||
);
|
||||
|
||||
SetCompletedWhenFinalized();
|
||||
|
||||
@@ -427,15 +427,18 @@ public class NovelUpdateService
|
||||
.Where(c => c.Body?.Texts == null || !c.Body.Texts.Any())
|
||||
.ToList();
|
||||
|
||||
var hasCoverToUpload = shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null;
|
||||
|
||||
// Publish metadata imported event for saga
|
||||
await _publishEndpoint.Publish<INovelMetadataImported>(new NovelMetadataImported(
|
||||
importId,
|
||||
novel.Id,
|
||||
chaptersNeedingPull.Count
|
||||
chaptersNeedingPull.Count,
|
||||
hasCoverToUpload
|
||||
));
|
||||
|
||||
// Publish cover image event if needed
|
||||
if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null)
|
||||
if (hasCoverToUpload)
|
||||
{
|
||||
await _publishEndpoint.Publish<IFileUploadRequestCreated>(new FileUploadRequestCreated(
|
||||
importId,
|
||||
@@ -568,7 +571,7 @@ public class NovelUpdateService
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<NovelImportRequested> QueueNovelImport(string novelUrl)
|
||||
public async Task<ImportNovelResult> QueueNovelImport(string novelUrl)
|
||||
{
|
||||
var importId = Guid.NewGuid();
|
||||
var activeImport = new ActiveImport
|
||||
@@ -590,7 +593,7 @@ public class NovelUpdateService
|
||||
|
||||
var importNovelRequestEvent = new NovelImportRequested(importId, novelUrl);
|
||||
await _publishEndpoint.Publish<INovelImportRequested>(importNovelRequestEvent);
|
||||
return importNovelRequestEvent;
|
||||
return new ImportNovelResult(importId, novelUrl);
|
||||
}
|
||||
|
||||
public async Task<ChapterPullRequested> QueueChapterPull(Guid importId, uint novelId, uint volumeId, uint chapterOrder)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Text.Json;
|
||||
using FictionArchive.Common.Enums;
|
||||
using FictionArchive.Service.ReportingService.Consumers;
|
||||
using FictionArchive.Service.ReportingService.Models;
|
||||
using FictionArchive.Service.ReportingService.Services;
|
||||
using FictionArchive.Service.Shared.Contracts.Events;
|
||||
using FluentAssertions;
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace FictionArchive.Service.ReportingService.Tests.Consumers;
|
||||
|
||||
public class JobStatusUpdateConsumerTests : IDisposable
|
||||
{
|
||||
private readonly ReportingDbContext _dbContext;
|
||||
private readonly JobStatusUpdateConsumer _consumer;
|
||||
|
||||
public JobStatusUpdateConsumerTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ReportingDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
_dbContext = new TestReportingDbContext(options, NullLogger<ReportingDbContext>.Instance);
|
||||
_consumer = new JobStatusUpdateConsumer(
|
||||
NullLogger<JobStatusUpdateConsumer>.Instance,
|
||||
_dbContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_create_new_job_on_first_event()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
var context = CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job display",
|
||||
JobStatus.InProgress, null, new() { ["key1"] = "value1" }));
|
||||
|
||||
await _consumer.Consume(context);
|
||||
|
||||
var job = await _dbContext.Jobs.FindAsync(jobId);
|
||||
job.Should().NotBeNull();
|
||||
job!.JobType.Should().Be("TestJob");
|
||||
job.DisplayName.Should().Be("Test job display");
|
||||
job.Status.Should().Be(JobStatus.InProgress);
|
||||
job.Metadata.Should().ContainKey("key1").WhoseValue.Should().Be("value1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_update_status_on_subsequent_event()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
|
||||
// First event: create
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job",
|
||||
JobStatus.InProgress, null, null)));
|
||||
|
||||
// Second event: update
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job",
|
||||
JobStatus.Completed, null, null)));
|
||||
|
||||
var job = await _dbContext.Jobs.FindAsync(jobId);
|
||||
job!.Status.Should().Be(JobStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_merge_metadata_on_update()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
|
||||
// First event with initial metadata
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job",
|
||||
JobStatus.InProgress, null, new() { ["NovelId"] = "42" })));
|
||||
|
||||
// Second event with additional metadata
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job",
|
||||
JobStatus.Completed, null, new() { ["ChapterId"] = "7" })));
|
||||
|
||||
var job = await _dbContext.Jobs.FindAsync(jobId);
|
||||
job!.Metadata.Should().ContainKey("NovelId").WhoseValue.Should().Be("42");
|
||||
job.Metadata.Should().ContainKey("ChapterId").WhoseValue.Should().Be("7");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_not_overwrite_job_type_on_update()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "OriginalType", "Test job",
|
||||
JobStatus.InProgress, null, null)));
|
||||
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "DifferentType", "Test job",
|
||||
JobStatus.Completed, null, null)));
|
||||
|
||||
var job = await _dbContext.Jobs.FindAsync(jobId);
|
||||
job!.JobType.Should().Be("OriginalType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_not_overwrite_parent_job_id_on_update()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
var parentId = Guid.NewGuid();
|
||||
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, parentId, "TestJob", "Test job",
|
||||
JobStatus.InProgress, null, null)));
|
||||
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job",
|
||||
JobStatus.Completed, null, null)));
|
||||
|
||||
var job = await _dbContext.Jobs.FindAsync(jobId);
|
||||
job!.ParentJobId.Should().Be(parentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_set_error_message_on_failure()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job",
|
||||
JobStatus.Failed, "Something went wrong", null)));
|
||||
|
||||
var job = await _dbContext.Jobs.FindAsync(jobId);
|
||||
job!.Status.Should().Be(JobStatus.Failed);
|
||||
job.ErrorMessage.Should().Be("Something went wrong");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_store_parent_job_id()
|
||||
{
|
||||
var parentId = Guid.NewGuid();
|
||||
var childId = Guid.NewGuid();
|
||||
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
parentId, null, "ParentJob", "Parent",
|
||||
JobStatus.InProgress, null, null)));
|
||||
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
childId, parentId, "ChildJob", "Child",
|
||||
JobStatus.InProgress, null, null)));
|
||||
|
||||
var child = await _dbContext.Jobs.FindAsync(childId);
|
||||
child!.ParentJobId.Should().Be(parentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_handle_null_metadata_on_create()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job",
|
||||
JobStatus.InProgress, null, null)));
|
||||
|
||||
var job = await _dbContext.Jobs.FindAsync(jobId);
|
||||
job!.Metadata.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_add_metadata_to_job_with_null_metadata()
|
||||
{
|
||||
var jobId = Guid.NewGuid();
|
||||
|
||||
// First event: no metadata
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job",
|
||||
JobStatus.InProgress, null, null)));
|
||||
|
||||
// Second event: adds metadata
|
||||
await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate(
|
||||
jobId, null, "TestJob", "Test job",
|
||||
JobStatus.Completed, null, new() { ["result"] = "success" })));
|
||||
|
||||
var job = await _dbContext.Jobs.FindAsync(jobId);
|
||||
job!.Metadata.Should().ContainKey("result").WhoseValue.Should().Be("success");
|
||||
}
|
||||
|
||||
private static ConsumeContext<IJobStatusUpdate> CreateConsumeContext(JobStatusUpdate message)
|
||||
{
|
||||
var context = Substitute.For<ConsumeContext<IJobStatusUpdate>>();
|
||||
context.Message.Returns(message);
|
||||
return context;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dbContext.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-specific subclass that adds a JSON value converter for Dictionary properties,
|
||||
/// since the InMemory provider does not support the jsonb column type used in production.
|
||||
/// </summary>
|
||||
private class TestReportingDbContext : ReportingDbContext
|
||||
{
|
||||
public TestReportingDbContext(DbContextOptions options, ILogger<ReportingDbContext> logger)
|
||||
: base(options, logger)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Job>(entity =>
|
||||
{
|
||||
entity.Property(j => j.Metadata)
|
||||
.HasConversion(
|
||||
v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
||||
v => v == null ? null : JsonSerializer.Deserialize<Dictionary<string, string>>(v, (JsonSerializerOptions?)null))
|
||||
.HasColumnType(null!);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
// Skip base OnConfiguring to avoid adding AuditInterceptor
|
||||
// which is not needed for unit tests
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="NodaTime.Testing" Version="3.3.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FictionArchive.Service.ReportingService\FictionArchive.Service.ReportingService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,66 @@
|
||||
using FictionArchive.Service.ReportingService.Models;
|
||||
using FictionArchive.Service.ReportingService.Services;
|
||||
using FictionArchive.Service.Shared.Contracts.Events;
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FictionArchive.Service.ReportingService.Consumers;
|
||||
|
||||
public class JobStatusUpdateConsumer : IConsumer<IJobStatusUpdate>
|
||||
{
|
||||
private readonly ILogger<JobStatusUpdateConsumer> _logger;
|
||||
private readonly ReportingDbContext _dbContext;
|
||||
|
||||
public JobStatusUpdateConsumer(
|
||||
ILogger<JobStatusUpdateConsumer> logger,
|
||||
ReportingDbContext dbContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task Consume(ConsumeContext<IJobStatusUpdate> context)
|
||||
{
|
||||
var message = context.Message;
|
||||
|
||||
var existingJob = await _dbContext.Jobs.FirstOrDefaultAsync(j => j.Id == message.JobId);
|
||||
|
||||
if (existingJob == null)
|
||||
{
|
||||
var job = new Job
|
||||
{
|
||||
Id = message.JobId,
|
||||
ParentJobId = message.ParentJobId,
|
||||
JobType = message.JobType,
|
||||
DisplayName = message.DisplayName,
|
||||
Status = message.Status,
|
||||
ErrorMessage = message.ErrorMessage,
|
||||
Metadata = message.Metadata != null
|
||||
? new Dictionary<string, string>(message.Metadata)
|
||||
: null
|
||||
};
|
||||
|
||||
_dbContext.Jobs.Add(job);
|
||||
_logger.LogInformation("Created job {JobId} of type {JobType}", message.JobId, message.JobType);
|
||||
}
|
||||
else
|
||||
{
|
||||
existingJob.Status = message.Status;
|
||||
existingJob.DisplayName = message.DisplayName;
|
||||
existingJob.ErrorMessage = message.ErrorMessage;
|
||||
|
||||
if (message.Metadata != null)
|
||||
{
|
||||
existingJob.Metadata ??= new Dictionary<string, string>();
|
||||
foreach (var kvp in message.Metadata)
|
||||
{
|
||||
existingJob.Metadata[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Updated job {JobId} to status {Status}", message.JobId, message.Status);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
23
FictionArchive.Service.ReportingService/Dockerfile
Normal file
23
FictionArchive.Service.ReportingService/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj", "FictionArchive.Service.ReportingService/"]
|
||||
RUN dotnet restore "FictionArchive.Service.ReportingService/FictionArchive.Service.ReportingService.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/FictionArchive.Service.ReportingService"
|
||||
RUN dotnet build "./FictionArchive.Service.ReportingService.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./FictionArchive.Service.ReportingService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "FictionArchive.Service.ReportingService.dll"]
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
|
||||
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
71
FictionArchive.Service.ReportingService/GraphQL/Query.cs
Normal file
71
FictionArchive.Service.ReportingService/GraphQL/Query.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using FictionArchive.Service.ReportingService.Models.DTOs;
|
||||
using FictionArchive.Service.ReportingService.Services;
|
||||
using HotChocolate.Authorization;
|
||||
using HotChocolate.Data;
|
||||
|
||||
namespace FictionArchive.Service.ReportingService.GraphQL;
|
||||
|
||||
public class Query
|
||||
{
|
||||
[Authorize]
|
||||
//[UseProjection]
|
||||
[UseFirstOrDefault]
|
||||
public IQueryable<JobDto> GetJobById(
|
||||
Guid jobId,
|
||||
ReportingDbContext db)
|
||||
=> db.Jobs.Where(j => j.Id == jobId).Select(j => new JobDto
|
||||
{
|
||||
Id = j.Id,
|
||||
CreatedTime = j.CreatedTime,
|
||||
LastUpdatedTime = j.LastUpdatedTime,
|
||||
ParentJobId = j.ParentJobId,
|
||||
JobType = j.JobType,
|
||||
DisplayName = j.DisplayName,
|
||||
Status = j.Status,
|
||||
ErrorMessage = j.ErrorMessage,
|
||||
Metadata = j.Metadata,
|
||||
ChildJobs = j.ChildJobs.Select(c => new JobDto
|
||||
{
|
||||
Id = c.Id,
|
||||
CreatedTime = c.CreatedTime,
|
||||
LastUpdatedTime = c.LastUpdatedTime,
|
||||
ParentJobId = c.ParentJobId,
|
||||
JobType = c.JobType,
|
||||
DisplayName = c.DisplayName,
|
||||
Status = c.Status,
|
||||
ErrorMessage = c.ErrorMessage,
|
||||
Metadata = c.Metadata
|
||||
})
|
||||
});
|
||||
|
||||
[Authorize]
|
||||
[UsePaging]
|
||||
//[UseProjection]
|
||||
[UseFiltering]
|
||||
[UseSorting]
|
||||
public IQueryable<JobDto> GetJobs(ReportingDbContext db)
|
||||
=> db.Jobs.Select(j => new JobDto
|
||||
{
|
||||
Id = j.Id,
|
||||
CreatedTime = j.CreatedTime,
|
||||
LastUpdatedTime = j.LastUpdatedTime,
|
||||
ParentJobId = j.ParentJobId,
|
||||
JobType = j.JobType,
|
||||
DisplayName = j.DisplayName,
|
||||
Status = j.Status,
|
||||
ErrorMessage = j.ErrorMessage,
|
||||
Metadata = j.Metadata,
|
||||
ChildJobs = j.ChildJobs.Select(c => new JobDto
|
||||
{
|
||||
Id = c.Id,
|
||||
CreatedTime = c.CreatedTime,
|
||||
LastUpdatedTime = c.LastUpdatedTime,
|
||||
ParentJobId = c.ParentJobId,
|
||||
JobType = c.JobType,
|
||||
DisplayName = c.DisplayName,
|
||||
Status = c.Status,
|
||||
ErrorMessage = c.ErrorMessage,
|
||||
Metadata = c.Metadata
|
||||
})
|
||||
});
|
||||
}
|
||||
86
FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.Designer.cs
generated
Normal file
86
FictionArchive.Service.ReportingService/Migrations/20260130214338_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,86 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FictionArchive.Service.ReportingService.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FictionArchive.Service.ReportingService.Migrations
|
||||
{
|
||||
[DbContext(typeof(ReportingDbContext))]
|
||||
[Migration("20260130214338_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("JobType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Dictionary<string, string>>("Metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentJobId");
|
||||
|
||||
b.ToTable("Jobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.ReportingService.Models.Job", "ParentJob")
|
||||
.WithMany("ChildJobs")
|
||||
.HasForeignKey("ParentJobId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("ParentJob");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b =>
|
||||
{
|
||||
b.Navigation("ChildJobs");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FictionArchive.Service.ReportingService.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Jobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ParentJobId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
JobType = table.Column<string>(type: "text", nullable: false),
|
||||
DisplayName = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "text", nullable: true),
|
||||
Metadata = table.Column<Dictionary<string, string>>(type: "jsonb", nullable: true),
|
||||
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Jobs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Jobs_Jobs_ParentJobId",
|
||||
column: x => x.ParentJobId,
|
||||
principalTable: "Jobs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Jobs_ParentJobId",
|
||||
table: "Jobs",
|
||||
column: "ParentJobId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Jobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FictionArchive.Service.ReportingService.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FictionArchive.Service.ReportingService.Migrations
|
||||
{
|
||||
[DbContext(typeof(ReportingDbContext))]
|
||||
partial class ReportingDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("JobType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Dictionary<string, string>>("Metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentJobId");
|
||||
|
||||
b.ToTable("Jobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.ReportingService.Models.Job", "ParentJob")
|
||||
.WithMany("ChildJobs")
|
||||
.HasForeignKey("ParentJobId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("ParentJob");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.ReportingService.Models.Job", b =>
|
||||
{
|
||||
b.Navigation("ChildJobs");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FictionArchive.Common.Enums;
|
||||
using HotChocolate.Data;
|
||||
using NodaTime;
|
||||
|
||||
namespace FictionArchive.Service.ReportingService.Models.DTOs;
|
||||
|
||||
public class JobDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Instant CreatedTime { get; init; }
|
||||
public Instant LastUpdatedTime { get; init; }
|
||||
public Guid? ParentJobId { get; init; }
|
||||
public required string JobType { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public JobStatus Status { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
public IEnumerable<JobDto>? ChildJobs { get; init; }
|
||||
}
|
||||
18
FictionArchive.Service.ReportingService/Models/Job.cs
Normal file
18
FictionArchive.Service.ReportingService/Models/Job.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using FictionArchive.Common.Enums;
|
||||
using FictionArchive.Service.Shared.Models;
|
||||
|
||||
namespace FictionArchive.Service.ReportingService.Models;
|
||||
|
||||
public class Job : BaseEntity<Guid>
|
||||
{
|
||||
public Guid? ParentJobId { get; set; }
|
||||
public string JobType { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public JobStatus Status { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
|
||||
// Navigation
|
||||
public Job? ParentJob { get; set; }
|
||||
public List<Job> ChildJobs { get; set; } = [];
|
||||
}
|
||||
78
FictionArchive.Service.ReportingService/Program.cs
Normal file
78
FictionArchive.Service.ReportingService/Program.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using FictionArchive.Common.Extensions;
|
||||
using FictionArchive.Service.ReportingService.Consumers;
|
||||
using FictionArchive.Service.ReportingService.Services;
|
||||
using FictionArchive.Service.ReportingService.GraphQL;
|
||||
using FictionArchive.Service.Shared;
|
||||
using FictionArchive.Service.Shared.Extensions;
|
||||
|
||||
namespace FictionArchive.Service.ReportingService;
|
||||
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
|
||||
|
||||
builder.AddLocalAppsettings();
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
#region MassTransit
|
||||
|
||||
if (!isSchemaExport)
|
||||
{
|
||||
builder.Services.AddFictionArchiveMassTransit(
|
||||
builder.Configuration,
|
||||
x =>
|
||||
{
|
||||
x.AddConsumer<JobStatusUpdateConsumer>();
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GraphQL
|
||||
|
||||
builder.Services.AddGraphQLServer()
|
||||
.ApplySaneDefaults()
|
||||
.AddQueryType<Query>()
|
||||
.AddAuthorization();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Database
|
||||
|
||||
builder.Services.RegisterDbContext<ReportingDbContext>(
|
||||
builder.Configuration.GetConnectionString("DefaultConnection"),
|
||||
skipInfrastructure: isSchemaExport);
|
||||
|
||||
#endregion
|
||||
|
||||
// Authentication & Authorization
|
||||
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||
builder.Services.AddFictionArchiveAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Update database (skip in schema export mode)
|
||||
if (!isSchemaExport)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ReportingDbContext>();
|
||||
dbContext.UpdateDatabase();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGraphQL();
|
||||
|
||||
app.RunWithGraphQLCommands(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5140",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "graphql",
|
||||
"applicationUrl": "https://localhost:7310;http://localhost:5140",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using FictionArchive.Service.ReportingService.Models;
|
||||
using FictionArchive.Service.Shared.Services.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FictionArchive.Service.ReportingService.Services;
|
||||
|
||||
public class ReportingDbContext : FictionArchiveDbContext
|
||||
{
|
||||
public DbSet<Job> Jobs { get; set; }
|
||||
|
||||
public ReportingDbContext(DbContextOptions options, ILogger<ReportingDbContext> logger) : base(options, logger)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Job>(entity =>
|
||||
{
|
||||
entity.HasIndex(j => j.ParentJobId);
|
||||
|
||||
entity.Property(j => j.Metadata)
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.HasOne(j => j.ParentJob)
|
||||
.WithMany(j => j.ChildJobs)
|
||||
.HasForeignKey(j => j.ParentJobId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
27
FictionArchive.Service.ReportingService/appsettings.json
Normal file
27
FictionArchive.Service.ReportingService/appsettings.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=FictionArchive_Reporting;Username=postgres;password=postgres"
|
||||
},
|
||||
"RabbitMQ": {
|
||||
"ConnectionString": "amqp://localhost",
|
||||
"ClientIdentifier": "ReportingService"
|
||||
},
|
||||
"OIDC": {
|
||||
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||
"ValidateIssuer": true,
|
||||
"ValidateAudience": true,
|
||||
"ValidateLifetime": true,
|
||||
"ValidateIssuerSigningKey": true
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"subgraph": "Reporting",
|
||||
"http": {
|
||||
"baseAddress": "https://localhost:7310/graphql"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace FictionArchive.Service.SchedulerService.GraphQL;
|
||||
|
||||
public class Query
|
||||
{
|
||||
public async Task<IEnumerable<SchedulerJob>> GetJobs(JobManagerService jobManager)
|
||||
public async Task<IEnumerable<SchedulerJob>> GetScheduledJobs(JobManagerService jobManager)
|
||||
{
|
||||
return await jobManager.GetScheduledJobs();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using FictionArchive.Common.Enums;
|
||||
|
||||
namespace FictionArchive.Service.Shared.Contracts.Events;
|
||||
|
||||
public interface IJobStatusUpdate
|
||||
{
|
||||
Guid JobId { get; }
|
||||
Guid? ParentJobId { get; }
|
||||
string JobType { get; }
|
||||
string DisplayName { get; }
|
||||
JobStatus Status { get; }
|
||||
string? ErrorMessage { get; }
|
||||
Dictionary<string, string>? Metadata { get; }
|
||||
}
|
||||
|
||||
public record JobStatusUpdate(
|
||||
Guid JobId,
|
||||
Guid? ParentJobId,
|
||||
string JobType,
|
||||
string DisplayName,
|
||||
JobStatus Status,
|
||||
string? ErrorMessage,
|
||||
Dictionary<string, string>? Metadata) : IJobStatusUpdate;
|
||||
@@ -5,6 +5,7 @@ public interface INovelMetadataImported
|
||||
Guid ImportId { get; }
|
||||
uint NovelId { get; }
|
||||
int ChaptersPendingPull { get; }
|
||||
bool CoverImageQueued { get; }
|
||||
}
|
||||
|
||||
public record NovelMetadataImported(Guid ImportId, uint NovelId, int ChaptersPendingPull) : INovelMetadataImported;
|
||||
public record NovelMetadataImported(Guid ImportId, uint NovelId, int ChaptersPendingPull, bool CoverImageQueued) : INovelMetadataImported;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FictionArchive.Service.Shared.Services.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
|
||||
namespace FictionArchive.Service.Shared.Extensions;
|
||||
|
||||
@@ -21,9 +22,14 @@ public static class DatabaseExtensions
|
||||
}
|
||||
else
|
||||
{
|
||||
var dataSourceBuilder = new Npgsql.NpgsqlDataSourceBuilder(connectionString);
|
||||
dataSourceBuilder.UseNodaTime();
|
||||
dataSourceBuilder.UseJsonNet();
|
||||
var dataSource = dataSourceBuilder.Build();
|
||||
|
||||
services.AddDbContext<TContext>(options =>
|
||||
{
|
||||
options.UseNpgsql(connectionString, o =>
|
||||
options.UseNpgsql(dataSource, o =>
|
||||
{
|
||||
o.UseNodaTime();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using FictionArchive.Common.Enums;
|
||||
using FictionArchive.Service.Shared.Contracts.Events;
|
||||
using MassTransit;
|
||||
|
||||
namespace FictionArchive.Service.Shared.Extensions;
|
||||
|
||||
public static class JobStatusPublisher
|
||||
{
|
||||
public static Task ReportJobStatus(
|
||||
this IPublishEndpoint endpoint,
|
||||
Guid jobId,
|
||||
string jobType,
|
||||
string displayName,
|
||||
JobStatus status,
|
||||
Guid? parentJobId = null,
|
||||
string? errorMessage = null,
|
||||
Dictionary<string, string>? metadata = null)
|
||||
=> endpoint.Publish<IJobStatusUpdate>(new JobStatusUpdate(
|
||||
jobId, parentJobId, jobType, displayName, status, errorMessage, metadata));
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.Json.NET" Version="9.*" />
|
||||
<PackageReference Include="Polly" Version="8.6.5" />
|
||||
<PackageReference Include="MassTransit.RabbitMQ" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
|
||||
@@ -23,6 +23,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.User
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserNovelDataService", "FictionArchive.Service.UserNovelDataService\FictionArchive.Service.UserNovelDataService.csproj", "{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService", "FictionArchive.Service.ReportingService\FictionArchive.Service.ReportingService.csproj", "{F29F7969-2B40-4B92-A8F5-9544A4F700DC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService.Tests", "FictionArchive.Service.ReportingService.Tests\FictionArchive.Service.ReportingService.Tests.csproj", "{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -73,5 +77,13 @@ Global
|
||||
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A278565B-D440-4AB9-B2E2-41BA3B3AD82A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -157,6 +157,20 @@ services:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
reporting-service:
|
||||
image: git.orfl.xyz/conco/fictionarchive-reporting-service:latest
|
||||
networks:
|
||||
- fictionarchive
|
||||
environment:
|
||||
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_Reporting;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
|
||||
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ===========================================
|
||||
# API Gateway
|
||||
# ===========================================
|
||||
@@ -179,6 +193,7 @@ services:
|
||||
- file-service
|
||||
- user-service
|
||||
- usernoveldata-service
|
||||
- reporting-service
|
||||
restart: unless-stopped
|
||||
|
||||
# ===========================================
|
||||
|
||||
@@ -39,7 +39,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.importNovel?.novelUpdateRequestedEvent) {
|
||||
const mutationErrors = result.data?.importNovel?.errors;
|
||||
if (mutationErrors && mutationErrors.length > 0) {
|
||||
error = mutationErrors[0].message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.importNovel?.importNovelResult) {
|
||||
success = true;
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
|
||||
217
fictionarchive-web-astro/src/lib/components/JobFilters.svelte
Normal file
217
fictionarchive-web-astro/src/lib/components/JobFilters.svelte
Normal file
@@ -0,0 +1,217 @@
|
||||
<script lang="ts">
|
||||
import { Select } from 'bits-ui';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import type { JobStatus } from '$lib/graphql/__generated__/graphql';
|
||||
import {
|
||||
type JobFilters as JobFiltersType,
|
||||
JOB_STATUS_OPTIONS,
|
||||
hasActiveJobFilters,
|
||||
EMPTY_JOB_FILTERS,
|
||||
} from '$lib/utils/jobFilterParams';
|
||||
|
||||
interface Props {
|
||||
filters: JobFiltersType;
|
||||
onFilterChange: (filters: JobFiltersType) => void;
|
||||
availableJobTypes: string[];
|
||||
}
|
||||
|
||||
let { filters, onFilterChange, availableJobTypes }: Props = $props();
|
||||
|
||||
let searchInput = $state(filters.search);
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const selectedStatusLabels = $derived(
|
||||
filters.statuses
|
||||
.map((s) => JOB_STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s)
|
||||
.join(', ')
|
||||
);
|
||||
|
||||
function handleSearchInput(value: string) {
|
||||
searchInput = value;
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
onFilterChange({ ...filters, search: value });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleStatusChange(selected: string[]) {
|
||||
onFilterChange({ ...filters, statuses: selected as JobStatus[] });
|
||||
}
|
||||
|
||||
function handleJobTypeChange(value: string) {
|
||||
onFilterChange({ ...filters, jobType: value === '__all__' ? '' : value });
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchInput = '';
|
||||
onFilterChange({ ...EMPTY_JOB_FILTERS });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (filters.search !== searchInput && !searchTimeout) {
|
||||
searchInput = filters.search;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Search Input -->
|
||||
<div class="relative min-w-[200px] flex-1">
|
||||
<Search class="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search jobs..."
|
||||
value={searchInput}
|
||||
oninput={(e) => handleSearchInput(e.currentTarget.value)}
|
||||
class="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<Select.Root
|
||||
type="multiple"
|
||||
value={filters.statuses}
|
||||
onValueChange={(v) => handleStatusChange(v as string[])}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 min-w-[140px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span class="truncate text-left">
|
||||
{filters.statuses.length > 0 ? selectedStatusLabels : 'Status'}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 opacity-50" />
|
||||
</Select.Trigger>
|
||||
<Select.Content
|
||||
class="bg-popover text-popover-foreground z-50 max-h-60 min-w-[140px] overflow-auto rounded-md border p-1 shadow-md"
|
||||
>
|
||||
{#each JOB_STATUS_OPTIONS as option (option.value)}
|
||||
<Select.Item
|
||||
value={option.value}
|
||||
class="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
{#snippet children({ selected })}
|
||||
<div
|
||||
class="border-primary flex h-4 w-4 items-center justify-center rounded-sm border {selected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: ''}"
|
||||
>
|
||||
{#if selected}
|
||||
<Check class="h-3 w-3" />
|
||||
{/if}
|
||||
</div>
|
||||
<span>{option.label}</span>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<!-- Job Type Filter -->
|
||||
{#if availableJobTypes.length > 0}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={filters.jobType || '__all__'}
|
||||
onValueChange={(v) => v && handleJobTypeChange(v)}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 min-w-[140px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span class="truncate text-left">
|
||||
{filters.jobType || 'Job Type'}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 opacity-50" />
|
||||
</Select.Trigger>
|
||||
<Select.Content
|
||||
class="bg-popover text-popover-foreground z-50 max-h-60 min-w-[140px] overflow-auto rounded-md border p-1 shadow-md"
|
||||
>
|
||||
<Select.Item
|
||||
value="__all__"
|
||||
class="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none"
|
||||
>
|
||||
{#snippet children({ selected })}
|
||||
<div class="flex h-4 w-4 items-center justify-center">
|
||||
{#if selected}
|
||||
<Check class="h-3 w-3" />
|
||||
{/if}
|
||||
</div>
|
||||
<span>All Types</span>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{#each availableJobTypes as jobType (jobType)}
|
||||
<Select.Item
|
||||
value={jobType}
|
||||
class="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none"
|
||||
>
|
||||
{#snippet children({ selected })}
|
||||
<div class="flex h-4 w-4 items-center justify-center">
|
||||
{#if selected}
|
||||
<Check class="h-3 w-3" />
|
||||
{/if}
|
||||
</div>
|
||||
<span>{jobType}</span>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Clear Filters Button -->
|
||||
{#if hasActiveJobFilters(filters)}
|
||||
<Button variant="outline" size="sm" onclick={clearFilters} class="gap-1">
|
||||
<X class="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Active Filter Badges -->
|
||||
{#if hasActiveJobFilters(filters)}
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#if filters.search}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
Search: {filters.search}
|
||||
<button
|
||||
onclick={() => {
|
||||
searchInput = '';
|
||||
onFilterChange({ ...filters, search: '' });
|
||||
}}
|
||||
class="hover:bg-secondary-foreground/20 ml-1 rounded-full p-0.5"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#each filters.statuses as status (status)}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
{JOB_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status}
|
||||
<button
|
||||
onclick={() =>
|
||||
onFilterChange({ ...filters, statuses: filters.statuses.filter((s) => s !== status) })}
|
||||
class="hover:bg-secondary-foreground/20 ml-1 rounded-full p-0.5"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
{/each}
|
||||
|
||||
{#if filters.jobType}
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
Type: {filters.jobType}
|
||||
<button
|
||||
onclick={() => onFilterChange({ ...filters, jobType: '' })}
|
||||
class="hover:bg-secondary-foreground/20 ml-1 rounded-full p-0.5"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
168
fictionarchive-web-astro/src/lib/components/JobRow.svelte
Normal file
168
fictionarchive-web-astro/src/lib/components/JobRow.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import JobStatusBadge from './JobStatusBadge.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface MetadataEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ChildJob {
|
||||
id: string;
|
||||
jobType: string;
|
||||
displayName: string;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
metadata: MetadataEntry[] | null;
|
||||
createdTime: string;
|
||||
lastUpdatedTime: string;
|
||||
}
|
||||
|
||||
interface JobNode {
|
||||
id: string;
|
||||
parentJobId: string | null;
|
||||
jobType: string;
|
||||
displayName: string;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
metadata: MetadataEntry[] | null;
|
||||
createdTime: string;
|
||||
lastUpdatedTime: string;
|
||||
childJobs: ChildJob[] | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
job: JobNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
let { job, expanded, onToggle, columnCount }: Props = $props();
|
||||
|
||||
const children = $derived(job.childJobs ?? []);
|
||||
const hasChildren = $derived(children.length > 0);
|
||||
const metadata = $derived(job.metadata ?? []);
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(iso), { addSuffix: true });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeFull(iso: string): string {
|
||||
try {
|
||||
return format(new Date(iso), 'yyyy-MM-dd HH:mm:ss');
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Main row -->
|
||||
<tr class={cn("border-b hover:bg-muted/50 transition-colors", expanded && "bg-muted/30")}>
|
||||
<td class="w-10 px-3 py-3">
|
||||
{#if hasChildren}
|
||||
<button onclick={onToggle} class={cn("hover:bg-accent rounded p-1 transition-transform", expanded && "rotate-90")}>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-3 font-medium">{job.displayName}</td>
|
||||
<td class="px-3 py-3 text-muted-foreground text-sm">{job.jobType}</td>
|
||||
<td class="px-3 py-3"><JobStatusBadge status={job.status} /></td>
|
||||
<td class="px-3 py-3 text-sm text-muted-foreground">
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="cursor-default">{formatTime(job.createdTime)}</Tooltip.Trigger>
|
||||
<Tooltip.Content>{formatTimeFull(job.createdTime)}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-sm text-muted-foreground">
|
||||
{#if hasChildren}
|
||||
{children.length} sub-job{children.length !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded detail -->
|
||||
{#if expanded}
|
||||
<tr class="border-b bg-muted/20">
|
||||
<td colspan={columnCount} class="px-6 py-4">
|
||||
<div class="space-y-4">
|
||||
<!-- Job details -->
|
||||
<div class="grid grid-cols-2 gap-x-8 gap-y-2 text-sm max-w-lg">
|
||||
<span class="text-muted-foreground">Last Updated</span>
|
||||
<span>{formatTimeFull(job.lastUpdatedTime)}</span>
|
||||
</div>
|
||||
|
||||
{#if job.errorMessage}
|
||||
<div class="text-sm">
|
||||
<span class="text-muted-foreground font-medium">Error: </span>
|
||||
<span class="text-destructive">{job.errorMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Metadata -->
|
||||
{#if metadata.length > 0}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-muted-foreground mb-2">Metadata</h4>
|
||||
<div class="grid grid-cols-2 gap-x-8 gap-y-1 text-sm max-w-lg">
|
||||
{#each metadata as entry (entry.key)}
|
||||
<span class="text-muted-foreground">{entry.key}</span>
|
||||
<span class="break-all">{entry.value}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sub-jobs table -->
|
||||
{#if hasChildren}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-muted-foreground mb-2">Sub-jobs</h4>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-muted-foreground">
|
||||
<th class="px-3 py-2 font-medium">Name</th>
|
||||
<th class="px-3 py-2 font-medium">Type</th>
|
||||
<th class="px-3 py-2 font-medium">Status</th>
|
||||
<th class="px-3 py-2 font-medium">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each children as child (child.id)}
|
||||
<tr class="border-b last:border-0">
|
||||
<td class="px-3 py-2">{child.displayName}</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">{child.jobType}</td>
|
||||
<td class="px-3 py-2"><JobStatusBadge status={child.status} /></td>
|
||||
<td class="px-3 py-2 text-muted-foreground">
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="cursor-default">{formatTime(child.createdTime)}</Tooltip.Trigger>
|
||||
<Tooltip.Content>{formatTimeFull(child.createdTime)}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</td>
|
||||
</tr>
|
||||
{#if child.errorMessage}
|
||||
<tr class="border-b last:border-0">
|
||||
<td colspan="4" class="px-3 pb-2">
|
||||
<span class="text-destructive text-xs">{child.errorMessage}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
interface Props {
|
||||
status: string;
|
||||
}
|
||||
|
||||
let { status }: Props = $props();
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; class: string }> = {
|
||||
COMPLETED: { label: 'Completed', variant: 'default', class: 'bg-green-600 hover:bg-green-600/90' },
|
||||
IN_PROGRESS: { label: 'In Progress', variant: 'default', class: 'bg-yellow-500 hover:bg-yellow-500/90' },
|
||||
PENDING: { label: 'Pending', variant: 'secondary', class: '' },
|
||||
FAILED: { label: 'Failed', variant: 'destructive', class: '' },
|
||||
};
|
||||
|
||||
const config = $derived(statusConfig[status] ?? { label: status, variant: 'outline' as const, class: '' });
|
||||
</script>
|
||||
|
||||
<Badge variant={config.variant} class={config.class}>{config.label}</Badge>
|
||||
175
fictionarchive-web-astro/src/lib/components/JobsTab.svelte
Normal file
175
fictionarchive-web-astro/src/lib/components/JobsTab.svelte
Normal file
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { client } from '$lib/graphql/client';
|
||||
import { JobsDocument, type JobsQuery } from '$lib/graphql/__generated__/graphql';
|
||||
import JobsTable from './JobsTable.svelte';
|
||||
import JobFilters from './JobFilters.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import {
|
||||
type JobFilters as JobFiltersType,
|
||||
parseJobFiltersFromURL,
|
||||
syncJobFiltersToURL,
|
||||
jobFiltersToGraphQLWhere,
|
||||
jobSortToGraphQLOrder,
|
||||
hasActiveJobFilters,
|
||||
EMPTY_JOB_FILTERS,
|
||||
} from '$lib/utils/jobFilterParams';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type JobsConnection = NonNullable<JobsQuery['jobs']>;
|
||||
type JobEdge = NonNullable<JobsConnection['edges']>[number];
|
||||
type PageInfo = JobsConnection['pageInfo'];
|
||||
|
||||
let edges: JobEdge[] = $state([]);
|
||||
let pageInfo: PageInfo | null = $state(null);
|
||||
let fetching = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let initialLoad = $state(true);
|
||||
let filters: JobFiltersType = $state({ ...EMPTY_JOB_FILTERS });
|
||||
|
||||
// Pagination: stack of "after" cursors used to reach each page
|
||||
// cursorStack[0] = the "after" cursor used to fetch page 2, etc.
|
||||
let cursorStack: (string | null)[] = $state([]);
|
||||
let currentPage = $derived(cursorStack.length + 1);
|
||||
|
||||
const jobs = $derived((edges ?? []).map((edge) => edge.node).filter(Boolean));
|
||||
|
||||
// Extract unique job types from loaded data for the filter dropdown
|
||||
const availableJobTypes = $derived(
|
||||
[...new Set(jobs.map((job) => job.jobType))].sort()
|
||||
);
|
||||
|
||||
async function fetchJobs(after: string | null = null) {
|
||||
fetching = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const where = jobFiltersToGraphQLWhere(filters);
|
||||
const order = jobSortToGraphQLOrder(filters.sort);
|
||||
const result = await client
|
||||
.query(JobsDocument, { first: PAGE_SIZE, after, where, order }, { requestPolicy: 'network-only' })
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.jobs) {
|
||||
edges = result.data.jobs.edges ?? [];
|
||||
pageInfo = result.data.jobs.pageInfo;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
fetching = false;
|
||||
initialLoad = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (pageInfo?.endCursor && pageInfo.hasNextPage) {
|
||||
cursorStack = [...cursorStack, pageInfo.endCursor];
|
||||
fetchJobs(pageInfo.endCursor);
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (cursorStack.length > 0) {
|
||||
const newStack = [...cursorStack];
|
||||
newStack.pop(); // remove current page's cursor
|
||||
cursorStack = newStack;
|
||||
// Fetch with the previous page's cursor (or null for page 1)
|
||||
const after = newStack.length > 0 ? newStack[newStack.length - 1] : null;
|
||||
fetchJobs(after);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterChange(newFilters: JobFiltersType) {
|
||||
filters = newFilters;
|
||||
edges = [];
|
||||
pageInfo = null;
|
||||
cursorStack = [];
|
||||
syncJobFiltersToURL(filters);
|
||||
fetchJobs();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
filters = parseJobFiltersFromURL();
|
||||
fetchJobs();
|
||||
|
||||
const handlePopState = () => {
|
||||
filters = parseJobFiltersFromURL();
|
||||
edges = [];
|
||||
pageInfo = null;
|
||||
cursorStack = [];
|
||||
fetchJobs();
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Card class="shadow-md shadow-primary/10">
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JobFilters {filters} onFilterChange={handleFilterChange} availableJobTypes={availableJobTypes} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if fetching && initialLoad}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div
|
||||
class="border-primary h-10 w-10 animate-spin rounded-full border-2 border-t-transparent"
|
||||
aria-label="Loading jobs"
|
||||
></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<Card class="border-destructive/40 bg-destructive/5">
|
||||
<CardContent>
|
||||
<p class="text-destructive py-4 text-sm">Could not load jobs: {error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if !initialLoad && !error && jobs.length === 0}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p class="text-muted-foreground py-4 text-sm">
|
||||
{#if hasActiveJobFilters(filters)}
|
||||
No jobs match your filters. Try adjusting your search criteria.
|
||||
{:else}
|
||||
No jobs found.
|
||||
{/if}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if jobs.length > 0}
|
||||
<JobsTable {jobs} />
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<Button variant="outline" size="sm" disabled={currentPage === 1 || fetching} onclick={prevPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span class="text-sm text-muted-foreground">Page {currentPage}</span>
|
||||
<Button variant="outline" size="sm" disabled={!pageInfo?.hasNextPage || fetching} onclick={nextPage}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
45
fictionarchive-web-astro/src/lib/components/JobsTable.svelte
Normal file
45
fictionarchive-web-astro/src/lib/components/JobsTable.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { JobsQuery } from '$lib/graphql/__generated__/graphql';
|
||||
import JobRow from './JobRow.svelte';
|
||||
|
||||
type JobNode = NonNullable<NonNullable<NonNullable<JobsQuery['jobs']>['edges']>[number]['node']>;
|
||||
|
||||
interface Props {
|
||||
jobs: JobNode[];
|
||||
}
|
||||
|
||||
let { jobs }: Props = $props();
|
||||
|
||||
let expandedJobId: string | null = $state(null);
|
||||
|
||||
const COLUMN_COUNT = 6;
|
||||
|
||||
function toggleRow(jobId: string) {
|
||||
expandedJobId = expandedJobId === jobId ? null : jobId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-md border">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/50">
|
||||
<th class="w-10 px-3 py-3"></th>
|
||||
<th class="px-3 py-3 text-sm font-medium text-muted-foreground">Name</th>
|
||||
<th class="px-3 py-3 text-sm font-medium text-muted-foreground">Type</th>
|
||||
<th class="px-3 py-3 text-sm font-medium text-muted-foreground">Status</th>
|
||||
<th class="px-3 py-3 text-sm font-medium text-muted-foreground">Created</th>
|
||||
<th class="px-3 py-3 text-sm font-medium text-muted-foreground">Sub-jobs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each jobs as job (job.id)}
|
||||
<JobRow
|
||||
{job}
|
||||
expanded={expandedJobId === job.id}
|
||||
onToggle={() => toggleRow(job.id)}
|
||||
columnCount={COLUMN_COUNT}
|
||||
/>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -29,7 +29,11 @@
|
||||
<NavigationMenu.Item>
|
||||
<NavigationMenu.Link href="/reading-lists" active={isActive('/reading-lists')}>Reading Lists</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
<NavigationMenu.Item>
|
||||
<NavigationMenu.Link href="/status" active={isActive('/status')}>Status</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
{/if}
|
||||
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import JobsTab from './JobsTab.svelte';
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Status</h1>
|
||||
<p class="text-muted-foreground">Monitor jobs and system activity</p>
|
||||
</div>
|
||||
|
||||
<Tabs.Root value="jobs">
|
||||
<Tabs.List class="mb-6">
|
||||
<Tabs.Trigger value="jobs">Jobs</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="jobs">
|
||||
<JobsTab />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
@@ -200,12 +200,20 @@ export type ImageDtoSortInput = {
|
||||
newPath?: InputMaybe<SortEnumType>;
|
||||
};
|
||||
|
||||
export type ImportNovelError = InvalidOperationError;
|
||||
|
||||
export type ImportNovelInput = {
|
||||
novelUrl: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type ImportNovelPayload = {
|
||||
novelImportRequested: Maybe<NovelImportRequested>;
|
||||
errors: Maybe<Array<ImportNovelError>>;
|
||||
importNovelResult: Maybe<ImportNovelResult>;
|
||||
};
|
||||
|
||||
export type ImportNovelResult = {
|
||||
importId: Scalars['UUID']['output'];
|
||||
novelUrl: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type InstantFilterInput = {
|
||||
@@ -249,6 +257,45 @@ export type InvitedUserDto = {
|
||||
username: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type JobDto = {
|
||||
childJobs: Maybe<Array<JobDto>>;
|
||||
createdTime: Scalars['Instant']['output'];
|
||||
displayName: Scalars['String']['output'];
|
||||
errorMessage: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['UUID']['output'];
|
||||
jobType: Scalars['String']['output'];
|
||||
lastUpdatedTime: Scalars['Instant']['output'];
|
||||
metadata: Maybe<Array<KeyValuePairOfStringAndString>>;
|
||||
parentJobId: Maybe<Scalars['UUID']['output']>;
|
||||
status: JobStatus;
|
||||
};
|
||||
|
||||
export type JobDtoFilterInput = {
|
||||
and?: InputMaybe<Array<JobDtoFilterInput>>;
|
||||
childJobs?: InputMaybe<ListFilterInputTypeOfJobDtoFilterInput>;
|
||||
createdTime?: InputMaybe<InstantFilterInput>;
|
||||
displayName?: InputMaybe<StringOperationFilterInput>;
|
||||
errorMessage?: InputMaybe<StringOperationFilterInput>;
|
||||
id?: InputMaybe<UuidOperationFilterInput>;
|
||||
jobType?: InputMaybe<StringOperationFilterInput>;
|
||||
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
|
||||
metadata?: InputMaybe<ListFilterInputTypeOfKeyValuePairOfStringAndStringFilterInput>;
|
||||
or?: InputMaybe<Array<JobDtoFilterInput>>;
|
||||
parentJobId?: InputMaybe<UuidOperationFilterInput>;
|
||||
status?: InputMaybe<JobStatusOperationFilterInput>;
|
||||
};
|
||||
|
||||
export type JobDtoSortInput = {
|
||||
createdTime?: InputMaybe<SortEnumType>;
|
||||
displayName?: InputMaybe<SortEnumType>;
|
||||
errorMessage?: InputMaybe<SortEnumType>;
|
||||
id?: InputMaybe<SortEnumType>;
|
||||
jobType?: InputMaybe<SortEnumType>;
|
||||
lastUpdatedTime?: InputMaybe<SortEnumType>;
|
||||
parentJobId?: InputMaybe<SortEnumType>;
|
||||
status?: InputMaybe<SortEnumType>;
|
||||
};
|
||||
|
||||
export type JobKey = {
|
||||
group: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
@@ -258,6 +305,39 @@ export type JobPersistenceError = Error & {
|
||||
message: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export const JobStatus = {
|
||||
Completed: 'COMPLETED',
|
||||
Failed: 'FAILED',
|
||||
InProgress: 'IN_PROGRESS',
|
||||
Pending: 'PENDING'
|
||||
} as const;
|
||||
|
||||
export type JobStatus = typeof JobStatus[keyof typeof JobStatus];
|
||||
export type JobStatusOperationFilterInput = {
|
||||
eq?: InputMaybe<JobStatus>;
|
||||
in?: InputMaybe<Array<JobStatus>>;
|
||||
neq?: InputMaybe<JobStatus>;
|
||||
nin?: InputMaybe<Array<JobStatus>>;
|
||||
};
|
||||
|
||||
/** A connection to a list of items. */
|
||||
export type JobsConnection = {
|
||||
/** A list of edges. */
|
||||
edges: Maybe<Array<JobsEdge>>;
|
||||
/** A flattened list of the nodes. */
|
||||
nodes: Maybe<Array<JobDto>>;
|
||||
/** Information to aid in pagination. */
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
/** An edge in a connection. */
|
||||
export type JobsEdge = {
|
||||
/** A cursor for use in pagination. */
|
||||
cursor: Scalars['String']['output'];
|
||||
/** The item at the end of the edge. */
|
||||
node: JobDto;
|
||||
};
|
||||
|
||||
export type KeyNotFoundError = Error & {
|
||||
message: Scalars['String']['output'];
|
||||
};
|
||||
@@ -267,6 +347,13 @@ export type KeyValuePairOfStringAndString = {
|
||||
value: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type KeyValuePairOfStringAndStringFilterInput = {
|
||||
and?: InputMaybe<Array<KeyValuePairOfStringAndStringFilterInput>>;
|
||||
key?: InputMaybe<StringOperationFilterInput>;
|
||||
or?: InputMaybe<Array<KeyValuePairOfStringAndStringFilterInput>>;
|
||||
value?: InputMaybe<StringOperationFilterInput>;
|
||||
};
|
||||
|
||||
export const Language = {
|
||||
Ch: 'CH',
|
||||
En: 'EN',
|
||||
@@ -296,6 +383,20 @@ export type ListFilterInputTypeOfImageDtoFilterInput = {
|
||||
some?: InputMaybe<ImageDtoFilterInput>;
|
||||
};
|
||||
|
||||
export type ListFilterInputTypeOfJobDtoFilterInput = {
|
||||
all?: InputMaybe<JobDtoFilterInput>;
|
||||
any?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
none?: InputMaybe<JobDtoFilterInput>;
|
||||
some?: InputMaybe<JobDtoFilterInput>;
|
||||
};
|
||||
|
||||
export type ListFilterInputTypeOfKeyValuePairOfStringAndStringFilterInput = {
|
||||
all?: InputMaybe<KeyValuePairOfStringAndStringFilterInput>;
|
||||
any?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
none?: InputMaybe<KeyValuePairOfStringAndStringFilterInput>;
|
||||
some?: InputMaybe<KeyValuePairOfStringAndStringFilterInput>;
|
||||
};
|
||||
|
||||
export type ListFilterInputTypeOfNovelTagDtoFilterInput = {
|
||||
all?: InputMaybe<NovelTagDtoFilterInput>;
|
||||
any?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
@@ -463,11 +564,6 @@ export type NovelDtoSortInput = {
|
||||
url?: InputMaybe<SortEnumType>;
|
||||
};
|
||||
|
||||
export type NovelImportRequested = {
|
||||
importId: Scalars['UUID']['output'];
|
||||
novelUrl: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export const NovelStatus = {
|
||||
Abandoned: 'ABANDONED',
|
||||
Completed: 'COMPLETED',
|
||||
@@ -573,10 +669,12 @@ export type Query = {
|
||||
bookmarks: Array<BookmarkDto>;
|
||||
chapter: Maybe<ChapterReaderDto>;
|
||||
currentUser: Maybe<UserDto>;
|
||||
jobs: Array<SchedulerJob>;
|
||||
jobById: Maybe<JobDto>;
|
||||
jobs: Maybe<JobsConnection>;
|
||||
novels: Maybe<NovelsConnection>;
|
||||
readingList: Maybe<ReadingListDto>;
|
||||
readingLists: Array<ReadingListDto>;
|
||||
scheduledJobs: Array<SchedulerJob>;
|
||||
translationEngines: Array<TranslationEngineDescriptor>;
|
||||
translationRequests: Maybe<TranslationRequestsConnection>;
|
||||
};
|
||||
@@ -595,6 +693,21 @@ export type QueryChapterArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryJobByIdArgs = {
|
||||
jobId: Scalars['UUID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryJobsArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
order?: InputMaybe<Array<JobDtoSortInput>>;
|
||||
where?: InputMaybe<JobDtoFilterInput>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryNovelsArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -1013,7 +1126,7 @@ export type ImportNovelMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type ImportNovelMutation = { importNovel: { novelImportRequested: { importId: any, novelUrl: string } | null } };
|
||||
export type ImportNovelMutation = { importNovel: { importNovelResult: { importId: any, novelUrl: string } | null, errors: Array<{ message: string }> | null } };
|
||||
|
||||
export type InviteUserMutationVariables = Exact<{
|
||||
input: InviteUserInput;
|
||||
@@ -1073,6 +1186,16 @@ export type GetChapterQueryVariables = Exact<{
|
||||
|
||||
export type GetChapterQuery = { chapter: { id: any, order: any, name: string, body: string, url: string | null, revision: any, createdTime: any, lastUpdatedTime: any, novelId: any, novelName: string, volumeId: any, volumeName: string, volumeOrder: number, totalChaptersInVolume: number, prevChapterVolumeOrder: number | null, prevChapterOrder: any | null, nextChapterVolumeOrder: number | null, nextChapterOrder: any | null, images: Array<{ id: any, newPath: string | null }> } | null };
|
||||
|
||||
export type JobsQueryVariables = Exact<{
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
where?: InputMaybe<JobDtoFilterInput>;
|
||||
order?: InputMaybe<Array<JobDtoSortInput> | JobDtoSortInput>;
|
||||
}>;
|
||||
|
||||
|
||||
export type JobsQuery = { jobs: { edges: Array<{ cursor: string, node: { id: any, parentJobId: any | null, jobType: string, displayName: string, status: JobStatus, errorMessage: string | null, createdTime: any, lastUpdatedTime: any, metadata: Array<{ key: string, value: string }> | null, childJobs: Array<{ id: any, jobType: string, displayName: string, status: JobStatus, errorMessage: string | null, createdTime: any, lastUpdatedTime: any, metadata: Array<{ key: string, value: string }> | null }> | null } }> | null, pageInfo: { hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null, endCursor: string | null } } | null };
|
||||
|
||||
export type NovelQueryVariables = Exact<{
|
||||
id: Scalars['UnsignedInt']['input'];
|
||||
}>;
|
||||
@@ -1117,7 +1240,7 @@ export const AddToReadingListDocument = {"kind":"Document","definitions":[{"kind
|
||||
export const CreateReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<CreateReadingListMutation, CreateReadingListMutationVariables>;
|
||||
export const DeleteNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boolean"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<DeleteNovelMutation, DeleteNovelMutationVariables>;
|
||||
export const DeleteReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<DeleteReadingListMutation, DeleteReadingListMutationVariables>;
|
||||
export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelImportRequested"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importId"}},{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode<ImportNovelMutation, ImportNovelMutationVariables>;
|
||||
export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovelResult"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importId"}},{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<ImportNovelMutation, ImportNovelMutationVariables>;
|
||||
export const InviteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InviteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InviteUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDto"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<InviteUserMutation, InviteUserMutationVariables>;
|
||||
export const RemoveBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeBookmark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<RemoveBookmarkMutation, RemoveBookmarkMutationVariables>;
|
||||
export const RemoveFromReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveFromReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveFromReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeFromReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<RemoveFromReadingListMutation, RemoveFromReadingListMutationVariables>;
|
||||
@@ -1126,6 +1249,7 @@ export const UpdateReadingListDocument = {"kind":"Document","definitions":[{"kin
|
||||
export const UpsertBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpsertBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpsertBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upsertBookmark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"bookmark"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"chapterId"}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<UpsertBookmarkMutation, UpsertBookmarkMutationVariables>;
|
||||
export const GetBookmarksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBookmarks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"chapterId"}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}}]} as unknown as DocumentNode<GetBookmarksQuery, GetBookmarksQueryVariables>;
|
||||
export const GetChapterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"volumeOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"volumeOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"volumeOrder"}}},{"kind":"Argument","name":{"kind":"Name","value":"chapterOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"revision"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"novelName"}},{"kind":"Field","name":{"kind":"Name","value":"volumeId"}},{"kind":"Field","name":{"kind":"Name","value":"volumeName"}},{"kind":"Field","name":{"kind":"Name","value":"volumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"totalChaptersInVolume"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterVolumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterVolumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterOrder"}}]}}]}}]} as unknown as DocumentNode<GetChapterQuery, GetChapterQueryVariables>;
|
||||
export const JobsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Jobs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JobDtoFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"order"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JobDtoSortInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jobs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"Variable","name":{"kind":"Name","value":"order"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentJobId"}},{"kind":"Field","name":{"kind":"Name","value":"jobType"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"childJobs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"jobType"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode<JobsQuery, JobsQueryVariables>;
|
||||
export const NovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rawLanguage"}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"statusOverride"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"source"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"volumes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<NovelQuery, NovelQueryVariables>;
|
||||
export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"order"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoSortInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"Variable","name":{"kind":"Name","value":"order"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"volumes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode<NovelsQuery, NovelsQueryVariables>;
|
||||
export const GetReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"addedTime"}}]}}]}}]}}]} as unknown as DocumentNode<GetReadingListQuery, GetReadingListQueryVariables>;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
mutation ImportNovel($input: ImportNovelInput!) {
|
||||
importNovel(input: $input) {
|
||||
novelImportRequested {
|
||||
importNovelResult {
|
||||
importId
|
||||
novelUrl
|
||||
}
|
||||
errors {
|
||||
... on InvalidOperationError {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
query Jobs($first: Int, $after: String, $where: JobDtoFilterInput, $order: [JobDtoSortInput!]) {
|
||||
jobs(first: $first, after: $after, where: $where, order: $order) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
parentJobId
|
||||
jobType
|
||||
displayName
|
||||
status
|
||||
errorMessage
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
createdTime
|
||||
lastUpdatedTime
|
||||
childJobs {
|
||||
id
|
||||
jobType
|
||||
displayName
|
||||
status
|
||||
errorMessage
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
createdTime
|
||||
lastUpdatedTime
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
131
fictionarchive-web-astro/src/lib/utils/jobFilterParams.ts
Normal file
131
fictionarchive-web-astro/src/lib/utils/jobFilterParams.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { JobDtoFilterInput, JobDtoSortInput, JobStatus, SortEnumType } from '$lib/graphql/__generated__/graphql';
|
||||
|
||||
export type JobSortField = 'createdTime' | 'lastUpdatedTime' | 'status';
|
||||
export type JobSortDirection = SortEnumType;
|
||||
|
||||
export interface JobSort {
|
||||
field: JobSortField;
|
||||
direction: JobSortDirection;
|
||||
}
|
||||
|
||||
export interface JobFilters {
|
||||
search: string;
|
||||
statuses: JobStatus[];
|
||||
jobType: string;
|
||||
sort: JobSort;
|
||||
}
|
||||
|
||||
export const JOB_STATUS_OPTIONS: { value: JobStatus; label: string }[] = [
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||
{ value: 'COMPLETED', label: 'Completed' },
|
||||
{ value: 'FAILED', label: 'Failed' },
|
||||
];
|
||||
|
||||
export const DEFAULT_JOB_SORT: JobSort = {
|
||||
field: 'createdTime',
|
||||
direction: 'DESC',
|
||||
};
|
||||
|
||||
export const EMPTY_JOB_FILTERS: JobFilters = {
|
||||
search: '',
|
||||
statuses: [],
|
||||
jobType: '',
|
||||
sort: DEFAULT_JOB_SORT,
|
||||
};
|
||||
|
||||
const VALID_STATUSES: string[] = JOB_STATUS_OPTIONS.map((o) => o.value);
|
||||
const VALID_SORT_FIELDS: JobSortField[] = ['createdTime', 'lastUpdatedTime', 'status'];
|
||||
const VALID_SORT_DIRECTIONS: JobSortDirection[] = ['ASC', 'DESC'];
|
||||
|
||||
export function parseJobFiltersFromURL(searchParams?: URLSearchParams): JobFilters {
|
||||
const params = searchParams ?? new URLSearchParams(window.location.search);
|
||||
|
||||
const search = params.get('search') ?? '';
|
||||
const jobType = params.get('jobType') ?? '';
|
||||
|
||||
const statusParam = params.get('status') ?? '';
|
||||
const statuses = statusParam
|
||||
.split(',')
|
||||
.filter((s) => s && VALID_STATUSES.includes(s)) as JobStatus[];
|
||||
|
||||
const sortField = params.get('sortBy') as JobSortField | null;
|
||||
const sortDir = params.get('sortDir') as JobSortDirection | null;
|
||||
const sort: JobSort = {
|
||||
field: sortField && VALID_SORT_FIELDS.includes(sortField) ? sortField : DEFAULT_JOB_SORT.field,
|
||||
direction: sortDir && VALID_SORT_DIRECTIONS.includes(sortDir) ? sortDir : DEFAULT_JOB_SORT.direction,
|
||||
};
|
||||
|
||||
return { search, statuses, jobType, sort };
|
||||
}
|
||||
|
||||
export function jobFiltersToURLParams(filters: JobFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.search.trim()) {
|
||||
params.set('search', filters.search.trim());
|
||||
}
|
||||
if (filters.statuses.length > 0) {
|
||||
params.set('status', filters.statuses.join(','));
|
||||
}
|
||||
if (filters.jobType.trim()) {
|
||||
params.set('jobType', filters.jobType.trim());
|
||||
}
|
||||
if (filters.sort.field !== DEFAULT_JOB_SORT.field || filters.sort.direction !== DEFAULT_JOB_SORT.direction) {
|
||||
params.set('sortBy', filters.sort.field);
|
||||
params.set('sortDir', filters.sort.direction);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function syncJobFiltersToURL(filters: JobFilters): void {
|
||||
const params = jobFiltersToURLParams(filters);
|
||||
const newUrl = params ? `${window.location.pathname}?${params}` : window.location.pathname;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
|
||||
export function jobFiltersToGraphQLWhere(filters: JobFilters): JobDtoFilterInput | null {
|
||||
const conditions: JobDtoFilterInput[] = [];
|
||||
|
||||
if (filters.search.trim()) {
|
||||
conditions.push({
|
||||
displayName: { contains: filters.search.trim() },
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.statuses.length > 0) {
|
||||
conditions.push({
|
||||
status: { in: filters.statuses },
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.jobType.trim()) {
|
||||
conditions.push({
|
||||
jobType: { eq: filters.jobType.trim() },
|
||||
});
|
||||
}
|
||||
|
||||
// Always filter to top-level jobs only (no parent)
|
||||
conditions.push({
|
||||
parentJobId: { eq: null },
|
||||
});
|
||||
|
||||
if (conditions.length === 1) {
|
||||
return conditions[0];
|
||||
}
|
||||
|
||||
return { and: conditions };
|
||||
}
|
||||
|
||||
export function jobSortToGraphQLOrder(sort: JobSort): JobDtoSortInput[] {
|
||||
return [{ [sort.field]: sort.direction }];
|
||||
}
|
||||
|
||||
export function hasActiveJobFilters(filters: JobFilters): boolean {
|
||||
return (
|
||||
filters.search.trim().length > 0 ||
|
||||
filters.statuses.length > 0 ||
|
||||
filters.jobType.trim().length > 0
|
||||
);
|
||||
}
|
||||
8
fictionarchive-web-astro/src/pages/status/index.astro
Normal file
8
fictionarchive-web-astro/src/pages/status/index.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import StatusPage from '../../lib/components/StatusPage.svelte';
|
||||
---
|
||||
|
||||
<AppLayout title="Status - FictionArchive">
|
||||
<StatusPage client:load />
|
||||
</AppLayout>
|
||||
Reference in New Issue
Block a user