harden code

This commit is contained in:
team 1
2026-02-15 16:01:08 +01:00
parent 5b100039e0
commit c099f72703
13 changed files with 397 additions and 59 deletions

View File

@@ -1,22 +1,7 @@
framework: framework:
messenger: messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports: transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration async: '%env(MESSENGER_TRANSPORT_DSN)%'
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing: routing:
# Route your messages to the transports #'App\Message\IngestDocumentMessage': async
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\IngestJob;
use App\Service\IngestOrchestrator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'mto:agent:ingest:run')]
final class IngestRunJobCommand extends Command
{
public function __construct(
private readonly IngestOrchestrator $orchestrator,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('jobId', InputArgument::REQUIRED, 'UUID of IngestJob');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$jobId = (string) $input->getArgument('jobId');
/** @var IngestJob|null $job */
$job = $this->em->getRepository(IngestJob::class)->find($jobId);
if (!$job) {
$output->writeln('<error>IngestJob not found.</error>');
return Command::FAILURE;
}
// Idempotenz: wenn der Job bereits beendet ist, einfach ok zurück.
if (in_array($job->getStatus(), [IngestJob::STATUS_COMPLETED, IngestJob::STATUS_FAILED, IngestJob::STATUS_ABORTED], true)) {
$output->writeln('<info>Job already finished.</info>');
return Command::SUCCESS;
}
try {
$output->writeln(sprintf('<info>Running ingest job %s ...</info>', (string) $job->getId()));
$this->orchestrator->runExistingJob($job, false);
$output->writeln('<info>Job completed.</info>');
return Command::SUCCESS;
} catch (\Throwable $e) {
$output->writeln(sprintf('<error>Job failed: %s</error>', $e->getMessage()));
return Command::FAILURE;
}
}
}

View File

@@ -46,7 +46,7 @@ class KnowledgeIngestCommand extends Command
$output->writeln('Starting ingest...'); $output->writeln('Starting ingest...');
$job = $this->orchestrator->runForVersion($version, $user, false); $job = $this->orchestrator->runForVersion($version, $user);
$output->writeln(sprintf('<info>Ingest completed. Job: %s</info>', (string) $job->getId())); $output->writeln(sprintf('<info>Ingest completed. Job: %s</info>', (string) $job->getId()));

View File

@@ -1,13 +1,13 @@
<?php <?php
namespace App\Controller\Admin; namespace App\Controller\Admin;
use App\Entity\Document; use App\Entity\Document;
use App\Entity\DocumentVersion; use App\Entity\DocumentVersion;
use App\Entity\IngestJob; use App\Entity\IngestJob;
use App\Service\DocumentService; use App\Service\DocumentService;
use App\Service\IngestOrchestrator; use App\Service\FormatText;
use App\Service\IngestJobService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -18,10 +18,15 @@ use Symfony\Component\Uid\Uuid;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\Exception\FileException;
#[Route('/admin/documents')] #[Route('/admin/documents')]
class DocumentController extends AbstractController class DocumentController extends AbstractController
{ {
public function __construct(
private readonly FormatText $formatText,
)
{
}
#[Route('', name: 'admin_documents')] #[Route('', name: 'admin_documents')]
public function index(EntityManagerInterface $em): Response public function index(EntityManagerInterface $em): Response
{ {
@@ -62,8 +67,10 @@ class DocumentController extends AbstractController
{ {
if ($request->isMethod('POST')) { if ($request->isMethod('POST')) {
$title = $request->request->get('title');
$file = $request->files->get('file'); $file = $request->files->get('file');
$title = $request->request->get('title') ?: $file->getClientOriginalName();
$title = $this->formatText->slugify($title);
if (!$file || !$title) { if (!$file || !$title) {
$this->addFlash('error', 'Titel und Datei sind erforderlich.'); $this->addFlash('error', 'Titel und Datei sind erforderlich.');
@@ -191,7 +198,7 @@ class DocumentController extends AbstractController
string $versionId, string $versionId,
Request $request, Request $request,
EntityManagerInterface $em, EntityManagerInterface $em,
IngestOrchestrator $orchestrator IngestJobService $jobService,
): ?RedirectResponse { ): ?RedirectResponse {
$dryRun = false; $dryRun = false;
if (!$this->isCsrfTokenValid('ingest_version', $request->request->get('_token'))) { if (!$this->isCsrfTokenValid('ingest_version', $request->request->get('_token'))) {
@@ -214,14 +221,47 @@ class DocumentController extends AbstractController
return null; return null;
} }
$orchestrator->runForVersion( // ---------------------------------------------------------
$version, // Asynchroner Ingest (ohne Messenger):
// 1) Job als QUEUED anlegen
// 2) Symfony-Command im Hintergrund starten
// 3) Direkt auf Job-Detailseite redirecten (Loader + Polling)
// ---------------------------------------------------------
$job = $jobService->startJob(
IngestJob::TYPE_DOCUMENT,
$this->getUser(), $this->getUser(),
$dryRun $version->getDocument()->getId(),
$version->getId(),
null,
IngestJob::STATUS_QUEUED
); );
return $this->redirectToRoute('admin_document_show', [ // Hintergrundprozess starten (Provider-kompatibel, kein Worker/Daemon)
'id' => $version->getDocument()->getId() $projectDir = (string) $this->getParameter('kernel.project_dir');
$console = $projectDir . '/bin/console';
$cmd = sprintf(
'%s %s %s %s > /dev/null 2>&1 &',
escapeshellarg($console),
escapeshellarg('mto:agent:ingest:run'),
escapeshellarg((string) $job->getId()),
escapeshellarg('--no-interaction'),
);
// Best effort: wenn exec deaktiviert ist, sauber abbrechen.
if (!\function_exists('exec')) {
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
$this->addFlash('error', 'Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).');
return $this->redirectToRoute('admin_document_show', [
'id' => $version->getDocument()->getId()
]);
}
exec($cmd);
return $this->redirectToRoute('admin_job_show', [
'id' => (string) $job->getId(),
]); ]);
} }

View File

@@ -1,6 +1,5 @@
<?php <?php
namespace App\Controller\Admin; namespace App\Controller\Admin;
use App\Entity\IngestJob; use App\Entity\IngestJob;
@@ -11,6 +10,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use App\Ingest\IngestFlow; use App\Ingest\IngestFlow;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
#[Route('/admin/jobs')] #[Route('/admin/jobs')]
class IngestJobController extends AbstractController class IngestJobController extends AbstractController
@@ -44,13 +44,40 @@ class IngestJobController extends AbstractController
]); ]);
} }
#[Route(
'/{id}/status',
name: 'admin_job_status',
requirements: ['id' => '[0-9a-fA-F\-]{36}'],
methods: ['GET']
)]
public function status(string $id, EntityManagerInterface $em): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
/** @var IngestJob|null $job */
$job = $em->getRepository(IngestJob::class)->find($id);
if (!$job) {
throw new NotFoundHttpException();
}
return $this->json([
'id' => (string) $job->getId(),
'type' => $job->getType(),
'status' => $job->getStatus(),
'startedAt' => $job->getStartedAt()->format(DATE_ATOM),
'finishedAt' => $job->getFinishedAt()?->format(DATE_ATOM),
'errorMessage' => $job->getErrorMessage(),
]);
}
#[Route('/global-reindex', name: 'admin_global_reindex', methods: ['POST'])] #[Route('/global-reindex', name: 'admin_global_reindex', methods: ['POST'])]
public function globalReindex( public function globalReindex(
IngestFlow $flow IngestFlow $flow
): RedirectResponse { ): RedirectResponse {
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$flow->globalReindex($this->getUser()); $flow->globalReindex();
return $this->redirectToRoute('admin_jobs'); return $this->redirectToRoute('admin_jobs');
} }

View File

@@ -11,6 +11,7 @@ class IngestJob
public const TYPE_DOCUMENT = 'DOCUMENT'; public const TYPE_DOCUMENT = 'DOCUMENT';
public const TYPE_GLOBAL_REINDEX = 'GLOBAL_REINDEX'; public const TYPE_GLOBAL_REINDEX = 'GLOBAL_REINDEX';
public const STATUS_QUEUED = 'QUEUED';
public const STATUS_RUNNING = 'RUNNING'; public const STATUS_RUNNING = 'RUNNING';
public const STATUS_COMPLETED = 'COMPLETED'; public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_FAILED = 'FAILED'; public const STATUS_FAILED = 'FAILED';
@@ -94,6 +95,11 @@ class IngestJob
$this->finishedAt = new \DateTimeImmutable(); $this->finishedAt = new \DateTimeImmutable();
} }
public function markRunning(): void
{
$this->status = self::STATUS_RUNNING;
}
public function getErrorMessage(): ?string public function getErrorMessage(): ?string
{ {
return $this->errorMessage; return $this->errorMessage;

View File

@@ -28,16 +28,12 @@ final readonly class IngestFlow
): void ): void
{ {
$this->metaManager->validateAgainstCurrent(); $this->metaManager->validateAgainstCurrent();
$this->chunkManager->compactByDocument( $this->chunkManager->compactByDocument(
$version->getDocument()->getId() $version->getDocument()->getId()
); );
$records = $this->knowledgeIngestService $records = $this->knowledgeIngestService
->buildChunkRecords($version); ->buildChunkRecords($version);
$this->chunkManager->appendChunks($records); $this->chunkManager->appendChunks($records);
$this->vectorBuilder->rebuildFromNdjson(); $this->vectorBuilder->rebuildFromNdjson();
} }

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Service;
class FormatText
{
function slugify(string $text): string
{
$text = mb_strtolower($text, 'UTF-8');
// Umlaute ersetzen
$replacements = [
'ä' => 'ae',
'ö' => 'oe',
'ü' => 'ue',
'ß' => 'ss'
];
$text = str_replace(array_keys($replacements), $replacements, $text);
// Nicht erlaubte Zeichen entfernen
$text = preg_replace('/[^a-z0-9\s.-]/', '', $text);
// Leerzeichen zu Bindestrichen
$text = preg_replace('/[\s-]+/', '-', $text);
$text = preg_replace('/\./', '-', $text);
return trim($text, '-');
}
}

View File

@@ -1,6 +1,5 @@
<?php <?php
namespace App\Service; namespace App\Service;
use App\Entity\IngestJob; use App\Entity\IngestJob;
@@ -19,10 +18,11 @@ final class IngestJobService
?User $user = null, ?User $user = null,
?Uuid $documentId = null, ?Uuid $documentId = null,
?Uuid $documentVersionId = null, ?Uuid $documentVersionId = null,
?string $logPath = null ?string $logPath = null,
string $status = IngestJob::STATUS_RUNNING
): IngestJob ): IngestJob
{ {
$job = new IngestJob($type); $job = new IngestJob($type, $status);
$job->setStartedBy($user); $job->setStartedBy($user);
$job->setDocumentId($documentId); $job->setDocumentId($documentId);
$job->setDocumentVersionId($documentVersionId); $job->setDocumentVersionId($documentVersionId);

View File

@@ -7,6 +7,7 @@ use App\Entity\IngestJob;
use App\Entity\User; use App\Entity\User;
use App\Ingest\IngestFlow; use App\Ingest\IngestFlow;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;
final class IngestOrchestrator final class IngestOrchestrator
{ {
@@ -19,14 +20,14 @@ final class IngestOrchestrator
} }
/** /**
* Startet Ingest für eine bestimmte DocumentVersion (1 Job pro Run). * SYNCHRONE Variante (falls noch genutzt)
* @throws \Throwable
*/ */
public function runForVersion( public function runForVersion(
DocumentVersion $version, DocumentVersion $version,
User $user, User $user,
bool $dryRun = false bool $dryRun = false
): IngestJob { ): IngestJob {
if (!$this->lockService->acquire()) { if (!$this->lockService->acquire()) {
throw new \RuntimeException('Another ingest job is already running.'); throw new \RuntimeException('Another ingest job is already running.');
} }
@@ -34,16 +35,12 @@ final class IngestOrchestrator
$job = null; $job = null;
try { try {
// Governance: nur PENDING/FAILED erlauben
$status = $version->getIngestStatus(); $status = $version->getIngestStatus();
if (!in_array($status, [
DocumentVersion::INGEST_PENDING, if ($status === DocumentVersion::INGEST_INDEXED) {
DocumentVersion::INGEST_FAILED, throw new \RuntimeException('DocumentVersion already indexed.');
], true)) {
throw new \RuntimeException(sprintf('Ingest not allowed for status "%s".', $status));
} }
// Job anlegen (einmal!)
$job = $this->jobService->startJob( $job = $this->jobService->startJob(
IngestJob::TYPE_DOCUMENT, IngestJob::TYPE_DOCUMENT,
$user, $user,
@@ -51,18 +48,15 @@ final class IngestOrchestrator
$version->getId(), $version->getId(),
); );
// Status → RUNNING
$version->setIngestStatus(DocumentVersion::INGEST_RUNNING); $version->setIngestStatus(DocumentVersion::INGEST_RUNNING);
$this->em->flush(); $this->em->flush();
if ($dryRun) { if ($dryRun) {
usleep(200000); usleep(200000);
} else { } else {
// Fachlogik ausführen (Flow erzeugt keine Jobs!)
$this->ingestFlow->ingestDocumentVersion($version); $this->ingestFlow->ingestDocumentVersion($version);
} }
// Erfolg
$version->setIngestStatus(DocumentVersion::INGEST_INDEXED); $version->setIngestStatus(DocumentVersion::INGEST_INDEXED);
$this->jobService->markCompleted($job); $this->jobService->markCompleted($job);
$this->em->flush(); $this->em->flush();
@@ -86,7 +80,120 @@ final class IngestOrchestrator
} }
/** /**
* Globaler Reindex aller aktiven Dokumente. * ASYNCHRONE Variante (Detached CLI)
*/
public function runExistingJob(IngestJob $job, bool $dryRun = false): void
{
if (!$this->lockService->acquire()) {
throw new \RuntimeException('Another ingest job is already running.');
}
try {
// Falls Job bereits final ist → nichts tun (idempotent)
if (in_array($job->getStatus(), [
IngestJob::STATUS_COMPLETED,
IngestJob::STATUS_FAILED,
IngestJob::STATUS_ABORTED,
], true)) {
return;
}
$job->markRunning();
$this->em->flush();
// Global Reindex
if ($job->getType() === IngestJob::TYPE_GLOBAL_REINDEX) {
if ($dryRun) {
usleep(200000);
} else {
$this->ingestFlow->globalReindex();
}
$this->jobService->markCompleted($job);
return;
}
if ($job->getType() !== IngestJob::TYPE_DOCUMENT) {
throw new \RuntimeException(sprintf(
'Unsupported ingest job type "%s".',
$job->getType()
));
}
$versionId = $job->getDocumentVersionId();
if (!$versionId instanceof Uuid) {
throw new \RuntimeException('Job has no document version id.');
}
/** @var DocumentVersion|null $version */
$version = $this->em
->getRepository(DocumentVersion::class)
->find($versionId);
if (!$version) {
throw new \RuntimeException('DocumentVersion not found.');
}
$status = $version->getIngestStatus();
// Nur blockieren wenn wirklich schon indexed
if ($status === DocumentVersion::INGEST_INDEXED) {
throw new \RuntimeException('DocumentVersion already indexed.');
}
// RUNNING darf hier erlaubt sein (async!)
if (!in_array($status, [
DocumentVersion::INGEST_PENDING,
DocumentVersion::INGEST_FAILED,
DocumentVersion::INGEST_RUNNING,
], true)) {
throw new \RuntimeException(sprintf(
'Ingest not allowed for status "%s".',
$status
));
}
$version->setIngestStatus(DocumentVersion::INGEST_RUNNING);
$this->em->flush();
if ($dryRun) {
usleep(200000);
} else {
$this->ingestFlow->ingestDocumentVersion($version);
}
$version->setIngestStatus(DocumentVersion::INGEST_INDEXED);
$this->jobService->markCompleted($job);
$this->em->flush();
} catch (\Throwable $e) {
$this->jobService->markFailed($job, $e->getMessage());
$versionId = $job->getDocumentVersionId();
if ($versionId instanceof Uuid) {
$version = $this->em
->getRepository(DocumentVersion::class)
->find($versionId);
if ($version) {
$version->setIngestStatus(DocumentVersion::INGEST_FAILED);
$this->em->flush();
}
}
throw $e;
} finally {
$this->lockService->release();
}
}
/**
* Globaler Reindex (synchron)
*/ */
public function runGlobal(User $user, bool $dryRun = false): IngestJob public function runGlobal(User $user, bool $dryRun = false): IngestJob
{ {
@@ -97,12 +204,15 @@ final class IngestOrchestrator
$job = null; $job = null;
try { try {
$job = $this->jobService->startJob(IngestJob::TYPE_GLOBAL_REINDEX, $user); $job = $this->jobService->startJob(
IngestJob::TYPE_GLOBAL_REINDEX,
$user
);
if ($dryRun) { if ($dryRun) {
usleep(200000); usleep(200000);
} else { } else {
$this->ingestFlow->globalReindex($job->getLogPath()); $this->ingestFlow->globalReindex();
} }
$this->jobService->markCompleted($job); $this->jobService->markCompleted($job);
@@ -110,6 +220,7 @@ final class IngestOrchestrator
return $job; return $job;
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($job) { if ($job) {
$this->jobService->markFailed($job, $e->getMessage()); $this->jobService->markFailed($job, $e->getMessage());
} }

View File

@@ -9,7 +9,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Titel</label> <label class="form-label">Titel</label>
<input class="form-control" name="title" required> <input class="form-control" name="title">
</div> </div>
<div class="mb-3"> <div class="mb-3">

View File

@@ -53,6 +53,8 @@
<td> <td>
{% if job.status == 'COMPLETED' %} {% if job.status == 'COMPLETED' %}
<span class="badge bg-success">COMPLETED</span> <span class="badge bg-success">COMPLETED</span>
{% elseif job.status == 'QUEUED' %}
<span class="badge bg-secondary">QUEUED</span>
{% elseif job.status == 'RUNNING' %} {% elseif job.status == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span> <span class="badge bg-warning text-dark">RUNNING</span>
{% elseif job.status == 'FAILED' %} {% elseif job.status == 'FAILED' %}
@@ -64,7 +66,7 @@
<td> <td>
{% if job.documentId %} {% if job.documentId %}
<a href="/admin/documents/{{ job.documentId }}" class="text-light">{{ job.documentId }} <a href="/admin/documents/{{ job.documentId }}" class="text-light">{{ job.documentId }}</a>
{% else %} {% else %}
- -
{% endif %} {% endif %}

View File

@@ -24,8 +24,11 @@
<div class="mb-2"> <div class="mb-2">
<strong>Status:</strong> <strong>Status:</strong>
<span id="job-status-badge">
{% if job.status == 'COMPLETED' %} {% if job.status == 'COMPLETED' %}
<span class="badge bg-success">COMPLETED</span> <span class="badge bg-success">COMPLETED</span>
{% elseif job.status == 'QUEUED' %}
<span class="badge bg-secondary">QUEUED</span>
{% elseif job.status == 'RUNNING' %} {% elseif job.status == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span> <span class="badge bg-warning text-dark">RUNNING</span>
{% elseif job.status == 'FAILED' %} {% elseif job.status == 'FAILED' %}
@@ -33,6 +36,7 @@
{% else %} {% else %}
<span class="badge bg-secondary">{{ job.status }}</span> <span class="badge bg-secondary">{{ job.status }}</span>
{% endif %} {% endif %}
</span>
</div> </div>
<div class="mb-2"> <div class="mb-2">
@@ -52,11 +56,13 @@
<div class="mb-2"> <div class="mb-2">
<strong>Beendet:</strong> <strong>Beendet:</strong>
{% if job.finishedAt %} <span id="job-finished-at">
{{ job.finishedAt|date('d.m.Y H:i:s') }} {% if job.finishedAt %}
{% else %} {{ job.finishedAt|date('d.m.Y H:i:s') }}
- {% else %}
{% endif %} -
{% endif %}
</span>
</div> </div>
<div class="mb-2"> <div class="mb-2">
@@ -68,6 +74,18 @@
{% endif %} {% endif %}
</div> </div>
<div id="job-loader" class="mt-3" style="display:none;">
<div class="d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
<div>
<strong>Ingest läuft…</strong><br>
<small class="text-secondary">Diese Seite aktualisiert den Status automatisch.</small>
</div>
</div>
</div>
<div id="job-error" class="alert alert-danger mt-3" style="display:none;"></div>
{% if job.errorMessage %} {% if job.errorMessage %}
<div class="alert alert-danger mt-3"> <div class="alert alert-danger mt-3">
<strong>Fehler:</strong><br> <strong>Fehler:</strong><br>
@@ -85,4 +103,67 @@
</div> </div>
</div> </div>
<script>
(function () {
const statusUrl = {{ path('admin_job_status', {id: job.id})|json_encode|raw }};
const badgeWrap = document.getElementById('job-status-badge');
const finishedAtEl = document.getElementById('job-finished-at');
const loaderEl = document.getElementById('job-loader');
const errorEl = document.getElementById('job-error');
let timer = null;
function stopPolling() {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
}
function setBadge(status) {
let html = '';
if (status === 'COMPLETED') html = '<span class="badge bg-success">COMPLETED</span>';
else if (status === 'QUEUED') html = '<span class="badge bg-secondary">QUEUED</span>';
else if (status === 'RUNNING') html = '<span class="badge bg-warning text-dark">RUNNING</span>';
else if (status === 'FAILED') html = '<span class="badge bg-danger">FAILED</span>';
else html = '<span class="badge bg-secondary">' + status + '</span>';
badgeWrap.innerHTML = html;
}
async function poll() {
try {
const res = await fetch(statusUrl);
if (!res.ok) return;
const data = await res.json();
const status = (data.status || '').toUpperCase();
setBadge(status);
finishedAtEl.textContent = data.finishedAt
? new Date(data.finishedAt).toLocaleString('de-DE')
: '-';
if (status === 'QUEUED' || status === 'RUNNING') {
loaderEl.style.display = '';
} else {
loaderEl.style.display = 'none';
stopPolling();
}
if (status === 'FAILED' && data.errorMessage) {
errorEl.style.display = '';
errorEl.innerHTML = '<strong>Fehler:</strong><br>' + data.errorMessage;
}
} catch (e) {
stopPolling();
}
}
timer = setInterval(poll, 1000);
poll();
})();
</script>
{% endblock %} {% endblock %}