diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiSchedulerTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiSchedulerTab.java index d544507..a55ae4b 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiSchedulerTab.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiSchedulerTab.java @@ -13,6 +13,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase; +import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus; @@ -81,6 +82,7 @@ public final class GuiSchedulerTab { private final Button stopButton = new Button("Scheduler stoppen"); private final Label nextTickLabel = new Label(); private final Label lastRunLabel = new Label("Noch kein Lauf in dieser Sitzung."); + private final Label sessionTotalsLabel = new Label(); private final Label lastErrorLabel = new Label(); private final TextField intervalField = new TextField(); private final Label intervalValidationLabel = new Label(); @@ -174,6 +176,7 @@ public final class GuiSchedulerTab { updateButtons(status); updateNextTickLabel(status); updateLastRunLabel(status); + updateSessionTotalsLabel(status); updateLastErrorLabel(status); updateIntervalFieldEditability(status); } @@ -199,6 +202,11 @@ public final class GuiSchedulerTab { lastRunLabel.setWrapText(true); + sessionTotalsLabel.setWrapText(true); + sessionTotalsLabel.setStyle("-fx-text-fill: #7f8c8d;"); + sessionTotalsLabel.setVisible(false); + sessionTotalsLabel.setManaged(false); + lastErrorLabel.setStyle("-fx-text-fill: #c0392b;"); lastErrorLabel.setWrapText(true); lastErrorLabel.setVisible(false); @@ -219,6 +227,7 @@ public final class GuiSchedulerTab { buttonBox, nextTickLabel, lastRunLabel, + sessionTotalsLabel, lastErrorLabel, new Separator(), intervalBox, @@ -337,19 +346,34 @@ public final class GuiSchedulerTab { RunSummary summary = status.lastRunSummary().get(); String timeStr = TIME_FORMATTER.format(endedAt); boolean noDocuments = summary.successCount() == 0 - && summary.failedCount() == 0 - && summary.skippedCount() == 0; + && summary.failedCount() == 0; if (noDocuments) { lastRunLabel.setText("Letzter Lauf: " + timeStr + " – keine neuen Dokumente"); } else { lastRunLabel.setText("Letzter Lauf: " + timeStr + " – " - + summary.successCount() + " Dokumente verarbeitet"); + + summary.successCount() + " verarbeitet, " + + summary.failedCount() + " Fehler"); } } else { lastRunLabel.setText("Noch kein Lauf in dieser Sitzung."); } } + private void updateSessionTotalsLabel(SchedulerStatus status) { + Optional totals = status.sessionTotals(); + if (totals.isPresent()) { + SchedulerSessionTotals t = totals.get(); + sessionTotalsLabel.setText("Seit Scheduler-Start: " + + t.successCount() + " verarbeitet, " + + t.failedCount() + " Fehler"); + sessionTotalsLabel.setVisible(true); + sessionTotalsLabel.setManaged(true); + } else { + sessionTotalsLabel.setVisible(false); + sessionTotalsLabel.setManaged(false); + } + } + private void updateLastErrorLabel(SchedulerStatus status) { Optional lastError = status.lastError(); if (lastError.isPresent() && !lastError.get().isBlank()) { diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerSessionTotals.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerSessionTotals.java new file mode 100644 index 0000000..4cfcc8e --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerSessionTotals.java @@ -0,0 +1,58 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +/** + * Aggregierte Zähler über alle abgeschlossenen Ticks der laufenden bzw. zuletzt + * gelaufenen Scheduler-Sitzung. + *

+ * Eine Sitzung beginnt mit dem nächsten erfolgreichen + * {@link SchedulerControlUseCase#start()} und endet mit dem zugehörigen + * {@link SchedulerControlUseCase#stop()}. Beim Start einer neuen Sitzung + * werden die Zähler auf null zurückgesetzt; nach dem Stopp bleiben sie + * eingefroren sichtbar, bis der Scheduler erneut gestartet wird. + *

+ * Übersprungene Dokumente werden in dieser Sitzungsstatistik bewusst nicht + * gezählt, da sie für den Bediener keine neue Verarbeitungsleistung darstellen. + * + * @param successCount Summe aller erfolgreich verarbeiteten Dokumente seit + * Sitzungsstart; nie negativ + * @param failedCount Summe aller fehlgeschlagenen Dokumente seit Sitzungsstart + * (retryable und final zusammengefasst); nie negativ + */ +public record SchedulerSessionTotals(int successCount, int failedCount) { + + /** + * Validiert, dass beide Zähler nicht negativ sind. + * + * @throws IllegalArgumentException wenn einer der Zähler kleiner als null ist + */ + public SchedulerSessionTotals { + if (successCount < 0 || failedCount < 0) { + throw new IllegalArgumentException( + "SchedulerSessionTotals counts must not be negative; was: " + + successCount + "/" + failedCount); + } + } + + /** + * Liefert ein neutrales Ausgangsobjekt mit beiden Zählern auf null. + * + * @return Sitzungstotal mit allen Zählern auf null; nie {@code null} + */ + public static SchedulerSessionTotals zero() { + return new SchedulerSessionTotals(0, 0); + } + + /** + * Liefert ein neues Sitzungstotal, in dem die übergebenen Werte additiv + * aufgenommen wurden. Das aktuelle Objekt bleibt unverändert. + * + * @param additionalSuccess hinzuzurechnende Erfolgreich-Zahl; muss ≥ 0 sein + * @param additionalFailed hinzuzurechnende Fehler-Zahl; muss ≥ 0 sein + * @return aufaddiertes Sitzungstotal; nie {@code null} + */ + public SchedulerSessionTotals plus(int additionalSuccess, int additionalFailed) { + return new SchedulerSessionTotals( + successCount + additionalSuccess, + failedCount + additionalFailed); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStatus.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStatus.java index 54c42e8..be47d71 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStatus.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/SchedulerStatus.java @@ -26,13 +26,19 @@ import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary; * @param lastError letzte aufgetretene deutsche Fehlermeldung; * wird bei erfolgreichem Lauf gelöscht, * bei {@code SkippedBusy} unverändert gelassen + * @param sessionTotals aggregierte Zähler seit dem letzten Sitzungsstart; + * leer vor dem allerersten {@code start()}, ab dem + * ersten erfolgreichen Start gefüllt und bei jedem + * weiteren Start auf null zurückgesetzt; nach dem + * Stopp bleibt der eingefrorene Endwert sichtbar */ public record SchedulerStatus( SchedulerState state, Optional lastRunEndedAt, Optional lastRunSummary, Optional nextTickAt, - Optional lastError + Optional lastError, + Optional sessionTotals ) { /** @@ -54,6 +60,9 @@ public record SchedulerStatus( if (lastError == null) { throw new IllegalArgumentException("lastError darf nicht null sein"); } + if (sessionTotals == null) { + throw new IllegalArgumentException("sessionTotals darf nicht null sein"); + } } /** @@ -70,6 +79,7 @@ public record SchedulerStatus( Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty() ); } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java index fc31475..196593e 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java @@ -1,6 +1,7 @@ package de.gecheckt.pdf.umbenenner.application.usecase; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase; +import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus; @@ -132,9 +133,15 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase { "Scheduler-Adapter konnte nicht gestartet werden.", e); } - // Schritt 4: Zustand auf RUNNING_IDLE setzen + // Schritt 4: Zustand auf RUNNING_IDLE setzen, Sitzungstotal auf null zurücksetzen Instant nextTick = Instant.now().plusSeconds(intervalSeconds); - statusRef.updateAndGet(s -> withState(s, SchedulerState.RUNNING_IDLE, Optional.of(nextTick))); + statusRef.updateAndGet(s -> new SchedulerStatus( + SchedulerState.RUNNING_IDLE, + s.lastRunEndedAt(), + s.lastRunSummary(), + Optional.of(nextTick), + s.lastError(), + Optional.of(SchedulerSessionTotals.zero()))); logger.info("Scheduler gestartet. Intervall: {} Sekunden.", intervalSeconds); } @@ -247,12 +254,17 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase { Optional lastRunEndedAt = afterBatch.lastRunEndedAt(); Optional lastRunSummary = afterBatch.lastRunSummary(); Optional lastError = afterBatch.lastError(); + Optional sessionTotals = afterBatch.sessionTotals(); switch (result) { case BatchRunTriggerResult.Started started -> { lastRunEndedAt = Optional.of(started.endedAt()); lastRunSummary = Optional.of(started.summary()); lastError = Optional.empty(); + sessionTotals = Optional.of(sessionTotals + .orElse(SchedulerSessionTotals.zero()) + .plus(started.summary().successCount(), + started.summary().failedCount())); logger.info("Scheduler-Tick abgeschlossen: {} erfolgreich, {} fehlgeschlagen, {} übersprungen.", started.summary().successCount(), started.summary().failedCount(), @@ -271,7 +283,8 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase { lastRunEndedAt, lastRunSummary, nextTickAt, - lastError)); + lastError, + sessionTotals)); if (stopping) { lockPort.releaseLock(); @@ -292,6 +305,7 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase { base.lastRunEndedAt(), base.lastRunSummary(), nextTickAt, - base.lastError()); + base.lastError(), + base.sessionTotals()); } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCaseTest.java index 9c3c374..b6ad361 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCaseTest.java @@ -19,6 +19,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState; import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus; @@ -63,6 +64,93 @@ class DefaultSchedulerControlUseCaseTest { assertThat(status.lastRunSummary()).isEmpty(); assertThat(status.nextTickAt()).isEmpty(); assertThat(status.lastError()).isEmpty(); + assertThat(status.sessionTotals()).isEmpty(); + } + + @Test + void start_initializesSessionTotalsToZero() { + DefaultSchedulerControlUseCase useCase = createUseCase(); + + useCase.start(); + + SchedulerStatus status = useCase.getStatus(); + assertThat(status.sessionTotals()).contains(SchedulerSessionTotals.zero()); + } + + @Test + void tick_started_accumulatesIntoSessionTotals() { + DefaultSchedulerControlUseCase useCase = createUseCase(); + useCase.start(); + when(batchRunTrigger.triggerRun()) + .thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(2, 1, 4))) + .thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(3, 0, 0))); + + useCase.executeWrappedTick(); + useCase.executeWrappedTick(); + + SchedulerStatus status = useCase.getStatus(); + assertThat(status.sessionTotals()).contains(new SchedulerSessionTotals(5, 1)); + } + + @Test + void tick_skippedBusy_doesNotChangeSessionTotals() { + DefaultSchedulerControlUseCase useCase = createUseCase(); + useCase.start(); + when(batchRunTrigger.triggerRun()) + .thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(2, 1, 0))) + .thenReturn(new BatchRunTriggerResult.SkippedBusy()); + + useCase.executeWrappedTick(); + useCase.executeWrappedTick(); + + SchedulerStatus status = useCase.getStatus(); + assertThat(status.sessionTotals()).contains(new SchedulerSessionTotals(2, 1)); + } + + @Test + void tick_failed_doesNotChangeSessionTotals() { + DefaultSchedulerControlUseCase useCase = createUseCase(); + useCase.start(); + when(batchRunTrigger.triggerRun()) + .thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(1, 1, 0))) + .thenReturn(new BatchRunTriggerResult.Failed("Fehler", "tech")); + + useCase.executeWrappedTick(); + useCase.executeWrappedTick(); + + SchedulerStatus status = useCase.getStatus(); + assertThat(status.sessionTotals()).contains(new SchedulerSessionTotals(1, 1)); + } + + @Test + void stop_keepsSessionTotalsVisible() { + DefaultSchedulerControlUseCase useCase = createUseCase(); + useCase.start(); + when(batchRunTrigger.triggerRun()) + .thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(7, 2, 0))); + useCase.executeWrappedTick(); + + useCase.stop(); + + SchedulerStatus status = useCase.getStatus(); + assertThat(status.state()).isEqualTo(SchedulerState.STOPPED); + assertThat(status.sessionTotals()).contains(new SchedulerSessionTotals(7, 2)); + } + + @Test + void start_afterPreviousSession_resetsSessionTotalsToZero() { + DefaultSchedulerControlUseCase useCase = createUseCase(); + useCase.start(); + when(batchRunTrigger.triggerRun()) + .thenReturn(new BatchRunTriggerResult.Started(Instant.now(), new RunSummary(5, 3, 0))); + useCase.executeWrappedTick(); + useCase.stop(); + assertThat(useCase.getStatus().sessionTotals()) + .contains(new SchedulerSessionTotals(5, 3)); + + useCase.start(); + + assertThat(useCase.getStatus().sessionTotals()).contains(SchedulerSessionTotals.zero()); } @Test diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index d6fcc26..8be38d3 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -1094,16 +1094,45 @@ public class BootstrapRunner { RunLockPort runLockPort = runLockPortFactory.create( resolveLockFilePath(ctx.startConfiguration())); BatchRunContext runContext = createRunContext(); + java.util.concurrent.atomic.AtomicReference + capturedSummary = new java.util.concurrent.atomic.AtomicReference<>(); + de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver capturingObserver = + new de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver() { + @Override + public void onRunStarted(RunId runId, int totalCandidates) { + // No GUI feedback for scheduler-driven runs. + } + + @Override + public void onDocumentCompleted( + de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent event) { + // No per-document feedback for scheduler-driven runs. + } + + @Override + public void onRunEnded( + de.gecheckt.pdf.umbenenner.application.port.in.RunSummary summary) { + capturedSummary.set(summary); + } + }; BatchRunProcessingUseCase useCase = buildProductionBatchUseCase( ctx.startConfiguration(), runLockPort, - de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver.noOp(), + capturingObserver, de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled()); BatchRunOutcome outcome = useCase.execute(runContext); runContext.setEndInstant(Instant.now()); if (outcome.isLockUnavailable()) { return new BatchRunTriggerResult.SkippedBusy(); } else if (outcome.isSuccess()) { - return new BatchRunTriggerResult.Started(Instant.now(), RunSummary.noOp()); + de.gecheckt.pdf.umbenenner.application.port.in.RunSummary inSummary = + capturedSummary.get(); + RunSummary outSummary = inSummary == null + ? RunSummary.noOp() + : new RunSummary( + inSummary.successCount(), + inSummary.failedCount(), + inSummary.skippedCount()); + return new BatchRunTriggerResult.Started(Instant.now(), outSummary); } else { return new BatchRunTriggerResult.Failed( "Verarbeitungslauf fehlgeschlagen.",