first commit
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
@@ -17,25 +18,22 @@ final class DashboardController extends AbstractController
|
||||
#[Route('', name: 'admin_dashboard_null')]
|
||||
#[Route('/', name: 'admin_dashboard_trail')]
|
||||
#[Route('/admin', name: 'admin_dashboard_alias')]
|
||||
public function trailNull(IndexMetaManager $metaManager,VectorIndexHealthService $health): RedirectResponse
|
||||
public function redirectToDashboard(): RedirectResponse
|
||||
{
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
|
||||
#[Route('/admin/dashboard', name: 'admin_dashboard')]
|
||||
public function dashboard(IndexMetaManager $metaManager,VectorIndexHealthService $health,TagVectorIndexHealthService $tagHealth): Response
|
||||
{
|
||||
$chunkCount = $metaManager->getRuntimeChunkCount();
|
||||
$limit = IngestFlow::CHUNK_LIMIT_HARD;
|
||||
|
||||
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
|
||||
public function dashboard(
|
||||
IndexMetaManager $metaManager,
|
||||
VectorIndexHealthService $health,
|
||||
TagVectorIndexHealthService $tagHealth
|
||||
): Response {
|
||||
return $this->render('admin/dashboard/index.html.twig', [
|
||||
'chunkCount' => $chunkCount,
|
||||
'chunkLimit' => $limit,
|
||||
'chunkCount' => $metaManager->getRuntimeChunkCount(),
|
||||
'chunkLimit' => IngestFlow::CHUNK_LIMIT_HARD,
|
||||
'vectorHealth' => $health->check(),
|
||||
'tagVectorHealth' => $tagHealth->check(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\Document;
|
||||
use App\Entity\DocumentVersion;
|
||||
use App\Entity\IngestJob;
|
||||
use App\Entity\User;
|
||||
use App\Service\DocumentService;
|
||||
use App\Service\FormatText;
|
||||
use App\Service\IngestJobService;
|
||||
@@ -23,9 +26,11 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[Route('/admin/documents')]
|
||||
class DocumentController extends AbstractController
|
||||
final class DocumentController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'admin_documents')]
|
||||
private const INGEST_DUPLICATE_WINDOW_SECONDS = 3;
|
||||
|
||||
#[Route('', name: 'admin_documents', methods: ['GET'])]
|
||||
public function index(EntityManagerInterface $em): Response
|
||||
{
|
||||
$documents = $em->getRepository(Document::class)
|
||||
@@ -46,115 +51,106 @@ class DocumentController extends AbstractController
|
||||
#[Route(
|
||||
'/{id}',
|
||||
name: 'admin_document_show',
|
||||
requirements: ['id' => '[0-9a-fA-F\-]{36}']
|
||||
requirements: ['id' => '[0-9a-fA-F\-]{36}'],
|
||||
methods: ['GET']
|
||||
)]
|
||||
public function show(string $id, EntityManagerInterface $em): Response
|
||||
{
|
||||
try {
|
||||
$uuid = Uuid::fromString($id);
|
||||
} catch (\Exception) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
$document = $em->getRepository(Document::class)->find($uuid);
|
||||
|
||||
if (!$document) {
|
||||
$this->addFlash('danger', 'Das Dokument existiert nicht mehr.');
|
||||
}
|
||||
|
||||
return $this->render('admin/document/show.html.twig', [
|
||||
'document' => $document,
|
||||
'document' => $this->findDocument($id, $em),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/new', name: 'admin_document_new')]
|
||||
#[Route('/new', name: 'admin_document_new', methods: ['GET', 'POST'])]
|
||||
public function new(
|
||||
Request $request,
|
||||
DocumentService $documentService,
|
||||
FormatText $formatText,
|
||||
IngestJobService $jobService,
|
||||
ParameterBagInterface $params
|
||||
Request $request,
|
||||
DocumentService $documentService,
|
||||
FormatText $formatText,
|
||||
IngestJobService $jobService,
|
||||
ParameterBagInterface $params,
|
||||
EntityManagerInterface $em,
|
||||
): Response {
|
||||
if (!$request->isMethod('POST')) {
|
||||
return $this->render('admin/document/new.html.twig');
|
||||
}
|
||||
|
||||
/** @var UploadedFile|null $file */
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new \InvalidArgumentException('No valid file uploaded.');
|
||||
}
|
||||
if (!$this->isCsrfTokenValid('create_document', (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
$rawTitle = $request->request->get('title');
|
||||
$title = is_string($rawTitle) && $rawTitle !== ''
|
||||
? $rawTitle
|
||||
: $formatText->slugify($file->getClientOriginalName());
|
||||
|
||||
if (!$title) {
|
||||
$this->addFlash('error', 'Titel ist erforderlich.');
|
||||
return $this->redirectToRoute('admin_document_new');
|
||||
}
|
||||
|
||||
$uploadDir = (string)$params->get('mto.vector.data.upload.path');
|
||||
$this->ensureDir($uploadDir);
|
||||
/** @var UploadedFile|null $file */
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
$this->addFlash('danger', 'Keine gültige Datei hochgeladen.');
|
||||
|
||||
$newFilename = uniqid('', true) . '_' . $file->getClientOriginalName();
|
||||
return $this->redirectToRoute('admin_document_new');
|
||||
}
|
||||
|
||||
$title = $this->resolveDocumentTitle($request, $file, $formatText);
|
||||
if ($title === '') {
|
||||
$this->addFlash('danger', 'Titel ist erforderlich.');
|
||||
|
||||
return $this->redirectToRoute('admin_document_new');
|
||||
}
|
||||
|
||||
$user = $this->requireUser();
|
||||
$uploadDir = trim((string) $params->get('mto.vector.data.upload.path'));
|
||||
|
||||
try {
|
||||
$file->move($uploadDir, $newFilename);
|
||||
} catch (FileException) {
|
||||
throw new \RuntimeException('File upload failed.');
|
||||
$this->ensureDir($uploadDir);
|
||||
$filePath = $this->moveUploadedFile($file, $uploadDir, $formatText);
|
||||
|
||||
$document = $documentService->createDocument($title, $filePath, $user);
|
||||
$version = $document->getCurrentVersion();
|
||||
|
||||
if (!$version instanceof DocumentVersion) {
|
||||
throw new \RuntimeException('Dokument erstellt, aber keine aktuelle Version vorhanden.');
|
||||
}
|
||||
|
||||
$job = $jobService->startJob(
|
||||
IngestJob::TYPE_DOCUMENT_VERSION_ACTIVATE,
|
||||
$user,
|
||||
$version->getDocument()->getId(),
|
||||
$version->getId(),
|
||||
null,
|
||||
IngestJob::STATUS_QUEUED
|
||||
);
|
||||
|
||||
$logFile = $this->prepareJobLogFile((string) $job->getId());
|
||||
$job->setLogPath($logFile);
|
||||
$em->flush();
|
||||
|
||||
if (!$this->canExec()) {
|
||||
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
|
||||
$this->addFlash('danger', 'Dokument erstellt, aber Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).');
|
||||
|
||||
return $this->redirectToRoute('admin_documents');
|
||||
}
|
||||
|
||||
$this->startIngestJob((string) $job->getId(), $logFile);
|
||||
|
||||
return $this->redirectToRoute('admin_job_show', [
|
||||
'id' => (string) $job->getId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Dokument konnte nicht erstellt werden.'));
|
||||
|
||||
return $this->redirectToRoute('admin_document_new');
|
||||
}
|
||||
|
||||
$filePath = $uploadDir . '/' . $newFilename;
|
||||
|
||||
$document = $documentService->createDocument(
|
||||
$title,
|
||||
$filePath,
|
||||
$this->getUser()
|
||||
);
|
||||
|
||||
$version = $document->getCurrentVersion();
|
||||
if (!$version instanceof DocumentVersion) {
|
||||
$this->addFlash('danger', 'Dokument erstellt, aber es wurde keine aktuelle Version erzeugt.');
|
||||
return $this->redirectToRoute('admin_documents');
|
||||
}
|
||||
|
||||
$job = $jobService->startJob(
|
||||
IngestJob::TYPE_DOCUMENT_VERSION_ACTIVATE,
|
||||
$this->getUser(),
|
||||
$version->getDocument()->getId(),
|
||||
$version->getId(),
|
||||
null,
|
||||
IngestJob::STATUS_QUEUED
|
||||
);
|
||||
|
||||
if (!$this->canExec()) {
|
||||
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
|
||||
$this->addFlash('danger', 'Dokument erstellt, aber Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).');
|
||||
return $this->redirectToRoute('admin_documents');
|
||||
}
|
||||
|
||||
$this->startIngestJob((string)$job->getId());
|
||||
|
||||
return $this->redirectToRoute('admin_job_show', [
|
||||
'id' => (string)$job->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/version/new', name: 'admin_document_version_new', requirements: ['id' => '[0-9a-fA-F\-]{36}'])]
|
||||
#[Route('/{id}/version/new', name: 'admin_document_version_new', requirements: ['id' => '[0-9a-fA-F\-]{36}'], methods: ['GET', 'POST'])]
|
||||
public function newVersion(
|
||||
string $id,
|
||||
Request $request,
|
||||
string $id,
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
DocumentService $documentService,
|
||||
ParameterBagInterface $params
|
||||
DocumentService $documentService,
|
||||
ParameterBagInterface $params,
|
||||
FormatText $formatText,
|
||||
): Response {
|
||||
$document = $em->getRepository(Document::class)->find($id);
|
||||
|
||||
if (!$document) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
$document = $this->findDocument($id, $em);
|
||||
|
||||
if (!$request->isMethod('POST')) {
|
||||
return $this->render('admin/document/new_version.html.twig', [
|
||||
@@ -162,31 +158,33 @@ class DocumentController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var UploadedFile|null $file */
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
$this->addFlash('error', 'Datei ist erforderlich.');
|
||||
if (!$this->isCsrfTokenValid('create_document_version_' . $id, (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
return $this->redirectToRoute('admin_document_version_new', ['id' => $id]);
|
||||
}
|
||||
|
||||
$uploadDir = (string)$params->get('mto.vector.data.upload.path');
|
||||
$this->ensureDir($uploadDir);
|
||||
/** @var UploadedFile|null $file */
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
$this->addFlash('danger', 'Datei ist erforderlich.');
|
||||
|
||||
$newFilename = uniqid('', true) . '_' . $file->getClientOriginalName();
|
||||
|
||||
try {
|
||||
$file->move($uploadDir, $newFilename);
|
||||
} catch (FileException) {
|
||||
throw new \RuntimeException('File upload failed.');
|
||||
return $this->redirectToRoute('admin_document_version_new', ['id' => $id]);
|
||||
}
|
||||
|
||||
$filePath = $uploadDir . '/' . $newFilename;
|
||||
try {
|
||||
$user = $this->requireUser();
|
||||
$uploadDir = trim((string) $params->get('mto.vector.data.upload.path'));
|
||||
$this->ensureDir($uploadDir);
|
||||
$filePath = $this->moveUploadedFile($file, $uploadDir, $formatText);
|
||||
|
||||
$documentService->addVersion(
|
||||
$document,
|
||||
$filePath,
|
||||
$this->getUser()
|
||||
);
|
||||
$documentService->addVersion($document, $filePath, $user);
|
||||
$this->addFlash('success', 'Neue Dokumentversion wurde hochgeladen.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Neue Dokumentversion konnte nicht erstellt werden.'));
|
||||
|
||||
return $this->redirectToRoute('admin_document_version_new', ['id' => $id]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_document_show', ['id' => $id]);
|
||||
}
|
||||
@@ -198,54 +196,55 @@ class DocumentController extends AbstractController
|
||||
methods: ['POST']
|
||||
)]
|
||||
public function activateVersion(
|
||||
string $versionId,
|
||||
Request $request,
|
||||
string $versionId,
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
DocumentService $documentService,
|
||||
IngestJobService $jobService,
|
||||
DocumentService $documentService,
|
||||
IngestJobService $jobService,
|
||||
): RedirectResponse {
|
||||
if (!$this->isCsrfTokenValid('activate_version_' . $versionId, (string)$request->request->get('_token'))) {
|
||||
if (!$this->isCsrfTokenValid('activate_version_' . $versionId, (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$version = $em->getRepository(DocumentVersion::class)->find($versionId);
|
||||
if (!$version) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
$version = $this->findDocumentVersion($versionId, $em);
|
||||
|
||||
try {
|
||||
$documentService->activateVersion($version);
|
||||
|
||||
$job = $jobService->startJob(
|
||||
IngestJob::TYPE_DOCUMENT_VERSION_ACTIVATE,
|
||||
$this->getUser(),
|
||||
$this->requireUser(),
|
||||
$version->getDocument()->getId(),
|
||||
$version->getId(),
|
||||
null,
|
||||
IngestJob::STATUS_QUEUED
|
||||
);
|
||||
|
||||
$logFile = $this->prepareJobLogFile((string) $job->getId());
|
||||
$job->setLogPath($logFile);
|
||||
$em->flush();
|
||||
|
||||
if (!$this->canExec()) {
|
||||
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
|
||||
$this->addFlash('danger', 'Aktivierung ok, aber Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).');
|
||||
|
||||
return $this->redirectToRoute('admin_document_show', [
|
||||
'id' => $version->getDocument()->getId(),
|
||||
'id' => (string) $version->getDocument()->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->startIngestJob((string)$job->getId());
|
||||
|
||||
$this->startIngestJob((string) $job->getId(), $logFile);
|
||||
$this->addFlash('success', 'Version aktiviert. Ingest-Job wurde erstellt und gestartet.');
|
||||
|
||||
return $this->redirectToRoute('admin_job_show', [
|
||||
'id' => (string)$job->getId(),
|
||||
'id' => (string) $job->getId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', 'Aktivierung/Re-Ingest fehlgeschlagen: ' . $e->getMessage());
|
||||
$this->addFlash('danger', 'Aktivierung/Re-Ingest fehlgeschlagen: ' . $this->buildSafeErrorMessage($e, 'Unbekannter Fehler.'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_document_show', [
|
||||
'id' => $version->getDocument()->getId(),
|
||||
'id' => (string) $version->getDocument()->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -256,115 +255,135 @@ class DocumentController extends AbstractController
|
||||
methods: ['POST']
|
||||
)]
|
||||
public function ingestVersion(
|
||||
string $versionId,
|
||||
Request $request,
|
||||
string $versionId,
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
IngestJobService $jobService,
|
||||
): ?RedirectResponse {
|
||||
if (!$this->isCsrfTokenValid('ingest_version_' . $versionId, (string)$request->request->get('_token'))) {
|
||||
IngestJobService $jobService,
|
||||
): RedirectResponse {
|
||||
if (!$this->isCsrfTokenValid('ingest_version_' . $versionId, (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$version = $em->getRepository(DocumentVersion::class)->find($versionId);
|
||||
if (!$version) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
$version = $this->findDocumentVersion($versionId, $em);
|
||||
|
||||
/** @var IngestJob|null $existing */
|
||||
$existing = $em->getRepository(IngestJob::class)
|
||||
->findOneBy(
|
||||
['documentVersionId' => $version->getId()],
|
||||
['startedAt' => 'DESC']
|
||||
['startedAt' => 'DESC', 'id' => 'DESC']
|
||||
);
|
||||
|
||||
if ($existing && $existing->getStartedAt() > new \DateTimeImmutable('-3 seconds')) {
|
||||
return null;
|
||||
if (
|
||||
$existing instanceof IngestJob
|
||||
&& $existing->getStartedAt() > new \DateTimeImmutable('-' . self::INGEST_DUPLICATE_WINDOW_SECONDS . ' seconds')
|
||||
&& in_array($existing->getStatus(), [IngestJob::STATUS_QUEUED, IngestJob::STATUS_RUNNING], true)
|
||||
) {
|
||||
$this->addFlash('info', 'Für diese Version läuft bereits ein aktueller Ingest-Job.');
|
||||
|
||||
return $this->redirectToRoute('admin_job_show', [
|
||||
'id' => (string) $existing->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$job = $jobService->startJob(
|
||||
IngestJob::TYPE_DOCUMENT,
|
||||
$this->getUser(),
|
||||
$this->requireUser(),
|
||||
$version->getDocument()->getId(),
|
||||
$version->getId(),
|
||||
null,
|
||||
IngestJob::STATUS_QUEUED
|
||||
);
|
||||
|
||||
$logFile = $this->prepareJobLogFile((string) $job->getId());
|
||||
$job->setLogPath($logFile);
|
||||
$em->flush();
|
||||
|
||||
if (!$this->canExec()) {
|
||||
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
|
||||
$this->addFlash('error', 'Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).');
|
||||
$this->addFlash('danger', 'Ingest konnte nicht asynchron gestartet werden (exec deaktiviert).');
|
||||
|
||||
return $this->redirectToRoute('admin_document_show', [
|
||||
'id' => $version->getDocument()->getId(),
|
||||
'id' => (string) $version->getDocument()->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->startIngestJob((string)$job->getId());
|
||||
try {
|
||||
$this->startIngestJob((string) $job->getId(), $logFile);
|
||||
} catch (\Throwable $e) {
|
||||
$jobService->markFailed($job, 'Ingest async start failed: ' . $e->getMessage());
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Ingest konnte nicht gestartet werden.'));
|
||||
|
||||
return $this->redirectToRoute('admin_document_show', [
|
||||
'id' => (string) $version->getDocument()->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_job_show', [
|
||||
'id' => (string)$job->getId(),
|
||||
'id' => (string) $job->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(
|
||||
'/reset',
|
||||
name: 'admin_document_reset',
|
||||
methods: ['POST']
|
||||
)]
|
||||
public function resetCompleteSystem(ParameterBagInterface $params, Connection $connection): ?RedirectResponse
|
||||
{
|
||||
if (!$this->canExec()) {
|
||||
$this->addFlash('danger', 'Der Reset konnte nicht gestartet werden (exec deaktiviert).');
|
||||
#[Route('/reset', name: 'admin_document_reset', methods: ['POST'])]
|
||||
public function resetCompleteSystem(
|
||||
Request $request,
|
||||
ParameterBagInterface $params,
|
||||
Connection $connection,
|
||||
): RedirectResponse {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
|
||||
if (!$this->isCsrfTokenValid('system_reset', (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
@unlink((string)$params->get('mto.knowledge.ndjson'));
|
||||
@unlink((string)$params->get('mto.knowledge.vector_index'));
|
||||
@unlink((string)$params->get('mto.knowledge.vector_index_meta'));
|
||||
@unlink((string)$params->get('mto.knowledge.index_meta'));
|
||||
@unlink((string)$params->get('mto.runtime.meta'));
|
||||
if (!$this->canExec()) {
|
||||
$this->addFlash('danger', 'Der Reset konnte nicht gestartet werden (exec deaktiviert).');
|
||||
|
||||
@unlink((string)$params->get('mto.knowledge.tags_ndjson'));
|
||||
@unlink((string)$params->get('mto.knowledge.vector_tags_index'));
|
||||
@unlink((string)$params->get('mto.knowledge.vector_tags_index_meta'));
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
$uploadDir = (string)$params->get('mto.knowledge.upload');
|
||||
foreach ([
|
||||
'mto.knowledge.ndjson',
|
||||
'mto.knowledge.vector_index',
|
||||
'mto.knowledge.vector_index_meta',
|
||||
'mto.knowledge.index_meta',
|
||||
'mto.runtime.meta',
|
||||
'mto.knowledge.tags_ndjson',
|
||||
'mto.knowledge.vector_tags_index',
|
||||
'mto.knowledge.vector_tags_index_meta',
|
||||
] as $parameterName) {
|
||||
$path = trim((string) $params->get($parameterName));
|
||||
if ($path !== '' && is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
$uploadDir = trim((string) $params->get('mto.knowledge.upload'));
|
||||
if ($uploadDir !== '' && is_dir($uploadDir)) {
|
||||
exec('rm -rf ' . escapeshellarg($uploadDir));
|
||||
}
|
||||
|
||||
$lockDir = (string)$params->get('mto.locks.dir');
|
||||
$lockDir = trim((string) $params->get('mto.locks.dir'));
|
||||
if ($lockDir !== '' && is_dir($lockDir)) {
|
||||
exec('rm -rf ' . escapeshellarg($lockDir));
|
||||
}
|
||||
|
||||
$sql = '
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
TRUNCATE TABLE db.document;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
TRUNCATE TABLE db.document_version;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
TRUNCATE TABLE db.ingest_job;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
TRUNCATE TABLE db.knowledge_tag;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
TRUNCATE TABLE db.tag_rebuild_job;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
TRUNCATE TABLE db.document_tag;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
';
|
||||
$connection->executeQuery($sql);
|
||||
$sql = <<<'SQL'
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
TRUNCATE TABLE db.document_tag;
|
||||
TRUNCATE TABLE db.tag_rebuild_job;
|
||||
TRUNCATE TABLE db.knowledge_tag;
|
||||
TRUNCATE TABLE db.ingest_job;
|
||||
TRUNCATE TABLE db.document_version;
|
||||
TRUNCATE TABLE db.document;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
SQL;
|
||||
|
||||
$connection->executeStatement($sql);
|
||||
|
||||
$this->addFlash('success', 'Das System wurde erfolgreich zurückgesetzt.');
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
@@ -375,62 +394,63 @@ class DocumentController extends AbstractController
|
||||
methods: ['POST']
|
||||
)]
|
||||
public function deleteDocument(
|
||||
string $id,
|
||||
Request $request,
|
||||
string $id,
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
IngestJobService $jobService,
|
||||
LockService $lockService,
|
||||
IngestJobService $jobService,
|
||||
LockService $lockService,
|
||||
): RedirectResponse {
|
||||
if (!$this->isCsrfTokenValid('delete_document_' . $id, (string)$request->request->get('_token'))) {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
|
||||
if (!$this->isCsrfTokenValid('delete_document_' . $id, (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
try {
|
||||
$uuid = Uuid::fromString($id);
|
||||
} catch (\Exception) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
/** @var Document|null $document */
|
||||
$document = $em->getRepository(Document::class)->find($uuid);
|
||||
if (!$document) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
$document = $this->findDocument($id, $em);
|
||||
|
||||
if (!$lockService->acquire()) {
|
||||
$this->addFlash('danger', 'Ein Ingest-Job läuft bereits. Löschen derzeit nicht möglich.');
|
||||
|
||||
return $this->redirectToRoute('admin_documents');
|
||||
}
|
||||
$lockService->release();
|
||||
|
||||
$job = $jobService->startJob(
|
||||
IngestJob::TYPE_DOCUMENT_DELETE,
|
||||
$this->getUser(),
|
||||
$this->requireUser(),
|
||||
$document->getId(),
|
||||
null,
|
||||
null,
|
||||
IngestJob::STATUS_QUEUED
|
||||
);
|
||||
|
||||
$logFile = $this->prepareJobLogFile((string) $job->getId());
|
||||
$job->setLogPath($logFile);
|
||||
$em->flush();
|
||||
|
||||
if (!$this->canExec()) {
|
||||
$jobService->markFailed($job, 'Server configuration does not allow background execution (exec disabled).');
|
||||
$this->addFlash('danger', 'Löschen konnte nicht gestartet werden (exec deaktiviert).');
|
||||
|
||||
return $this->redirectToRoute('admin_documents');
|
||||
}
|
||||
|
||||
$this->startIngestJob((string)$job->getId());
|
||||
try {
|
||||
$this->startIngestJob((string) $job->getId(), $logFile);
|
||||
} catch (\Throwable $e) {
|
||||
$jobService->markFailed($job, 'Delete async start failed: ' . $e->getMessage());
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Löschvorgang konnte nicht gestartet werden.'));
|
||||
|
||||
return $this->redirectToRoute('admin_documents');
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Löschvorgang gestartet. Dokument wird nach Index-Rebuild entfernt.');
|
||||
|
||||
return $this->redirectToRoute('admin_job_show', [
|
||||
'id' => (string)$job->getId(),
|
||||
'id' => (string) $job->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Helpers
|
||||
// =========================================================
|
||||
|
||||
private function canExec(): bool
|
||||
{
|
||||
if (!function_exists('exec')) {
|
||||
@@ -443,6 +463,7 @@ class DocumentController extends AbstractController
|
||||
}
|
||||
|
||||
$list = array_map('trim', explode(',', $disabled));
|
||||
|
||||
return !in_array('exec', $list, true);
|
||||
}
|
||||
|
||||
@@ -452,34 +473,209 @@ class DocumentController extends AbstractController
|
||||
throw new \RuntimeException('Upload directory not configured.');
|
||||
}
|
||||
|
||||
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new \RuntimeException('Unable to create upload directory.');
|
||||
}
|
||||
}
|
||||
|
||||
private function startIngestJob(string $jobId): void
|
||||
private function moveUploadedFile(UploadedFile $file, string $uploadDir, FormatText $formatText): string
|
||||
{
|
||||
$projectDir = (string)$this->getParameter('kernel.project_dir');
|
||||
$originalName = trim((string) $file->getClientOriginalName());
|
||||
$baseName = pathinfo($originalName !== '' ? $originalName : 'document', PATHINFO_FILENAME);
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
|
||||
$safeBaseName = $formatText->slugify($baseName !== '' ? $baseName : 'document');
|
||||
if ($safeBaseName === '') {
|
||||
$safeBaseName = 'document';
|
||||
}
|
||||
|
||||
$newFilename = uniqid('', true) . '_' . $safeBaseName;
|
||||
if ($extension !== '') {
|
||||
$newFilename .= '.' . $extension;
|
||||
}
|
||||
|
||||
try {
|
||||
$file->move($uploadDir, $newFilename);
|
||||
} catch (FileException) {
|
||||
throw new \RuntimeException('File upload failed.');
|
||||
}
|
||||
|
||||
return rtrim($uploadDir, '/') . '/' . $newFilename;
|
||||
}
|
||||
|
||||
private function resolveDocumentTitle(Request $request, UploadedFile $file, FormatText $formatText): string
|
||||
{
|
||||
$rawTitle = trim((string) $request->request->get('title', ''));
|
||||
if ($rawTitle !== '') {
|
||||
return $rawTitle;
|
||||
}
|
||||
|
||||
$originalName = trim((string) $file->getClientOriginalName());
|
||||
$baseName = pathinfo($originalName, PATHINFO_FILENAME);
|
||||
|
||||
return trim((string) $formatText->slugify($baseName !== '' ? $baseName : $originalName));
|
||||
}
|
||||
|
||||
private function startIngestJob(string $jobId, string $logFile): void
|
||||
{
|
||||
$projectDir = $this->resolveProjectDir();
|
||||
$console = $projectDir . '/bin/console';
|
||||
|
||||
$logDir = $projectDir . '/var/log/ingest';
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0777, true);
|
||||
if (!is_file($console)) {
|
||||
throw new \RuntimeException('bin/console not found: ' . $console);
|
||||
}
|
||||
$logFile = $logDir . '/job_' . $jobId . '.log';
|
||||
|
||||
// Wichtig: CLI-PHP verwenden, nicht PHP_BINARY aus FPM
|
||||
$php = 'php';
|
||||
$php = $this->resolvePhpBinary();
|
||||
|
||||
$cmd = sprintf(
|
||||
'%s %s --no-interaction %s %s >> %s 2>&1 &',
|
||||
escapeshellcmd($php),
|
||||
'cd %s && nohup %s %s %s %s --no-interaction >> %s 2>&1 & echo $!',
|
||||
escapeshellarg($projectDir),
|
||||
escapeshellarg($php),
|
||||
escapeshellarg($console),
|
||||
escapeshellarg('mto:agent:ingest:run'),
|
||||
escapeshellarg($jobId),
|
||||
escapeshellarg($logFile),
|
||||
);
|
||||
|
||||
exec($cmd);
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
@exec($cmd, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new \RuntimeException('Background ingest bootstrap failed with exit code ' . $exitCode . '.');
|
||||
}
|
||||
}
|
||||
|
||||
private function prepareJobLogFile(string $jobId): string
|
||||
{
|
||||
$projectDir = $this->resolveProjectDir();
|
||||
$logDir = $projectDir . '/var/log/ingest';
|
||||
$this->ensureDir($logDir);
|
||||
|
||||
return $logDir . '/job_' . $jobId . '.log';
|
||||
}
|
||||
|
||||
private function resolveProjectDir(): string
|
||||
{
|
||||
$projectDir = trim((string) $this->getParameter('kernel.project_dir'));
|
||||
|
||||
if ($projectDir === '' || !is_dir($projectDir)) {
|
||||
throw new \RuntimeException('Project directory is invalid.');
|
||||
}
|
||||
|
||||
return rtrim($projectDir, '/');
|
||||
}
|
||||
|
||||
private function resolvePhpBinary(): string
|
||||
{
|
||||
$envCandidates = [
|
||||
trim((string) ($_SERVER['PHP_CLI_BINARY'] ?? '')),
|
||||
trim((string) ($_ENV['PHP_CLI_BINARY'] ?? '')),
|
||||
trim((string) getenv('PHP_CLI_BINARY')),
|
||||
];
|
||||
|
||||
foreach ($envCandidates as $candidate) {
|
||||
if ($this->isValidCliPhpBinary($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$phpBinary = defined('PHP_BINARY') ? trim((string) PHP_BINARY) : '';
|
||||
if ($this->isValidCliPhpBinary($phpBinary)) {
|
||||
return $phpBinary;
|
||||
}
|
||||
|
||||
$fallbackCandidates = [
|
||||
'/usr/bin/php',
|
||||
'/usr/local/bin/php',
|
||||
'/bin/php',
|
||||
'/opt/homebrew/bin/php',
|
||||
];
|
||||
|
||||
foreach ($fallbackCandidates as $candidate) {
|
||||
if ($this->isValidCliPhpBinary($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$whichPhp = trim((string) @shell_exec('command -v php 2>/dev/null'));
|
||||
if ($this->isValidCliPhpBinary($whichPhp)) {
|
||||
return $whichPhp;
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
'Could not resolve a CLI PHP binary. Set PHP_CLI_BINARY explicitly, e.g. /usr/bin/php.'
|
||||
);
|
||||
}
|
||||
|
||||
private function isValidCliPhpBinary(string $path): bool
|
||||
{
|
||||
$path = trim($path);
|
||||
|
||||
if ($path === '' || !is_file($path) || !is_executable($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$basename = strtolower(basename($path));
|
||||
|
||||
if (str_contains($basename, 'fpm') || str_contains($basename, 'cgi')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function findDocument(string $id, EntityManagerInterface $em): Document
|
||||
{
|
||||
try {
|
||||
$uuid = Uuid::fromString(trim($id));
|
||||
} catch (\Throwable) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
/** @var Document|null $document */
|
||||
$document = $em->getRepository(Document::class)->find($uuid);
|
||||
|
||||
if (!$document instanceof Document) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function findDocumentVersion(string $versionId, EntityManagerInterface $em): DocumentVersion
|
||||
{
|
||||
try {
|
||||
$uuid = Uuid::fromString(trim($versionId));
|
||||
} catch (\Throwable) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
/** @var DocumentVersion|null $version */
|
||||
$version = $em->getRepository(DocumentVersion::class)->find($uuid);
|
||||
|
||||
if (!$version instanceof DocumentVersion) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
private function requireUser(): User
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
if (!$user instanceof User) {
|
||||
throw new \RuntimeException('No authenticated user available.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function buildSafeErrorMessage(\Throwable $e, string $fallback): string
|
||||
{
|
||||
$message = trim($e->getMessage());
|
||||
|
||||
return $message !== '' ? $message : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -19,44 +19,97 @@ final class DocumentTagController extends AbstractController
|
||||
#[Route('/{id}/tags', name: 'admin_document_tags_edit', methods: ['GET'])]
|
||||
public function edit(string $id, DocumentTagAdminService $svc): Response
|
||||
{
|
||||
$data = $svc->getEditData($id);
|
||||
$id = trim($id);
|
||||
|
||||
try {
|
||||
$data = $svc->getEditData($id);
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Dokument-Tags konnten nicht geladen werden.'));
|
||||
|
||||
return $this->redirectToRoute('admin_documents');
|
||||
}
|
||||
|
||||
return $this->render('admin/document_tags/edit.html.twig', [
|
||||
'document' => $data['document'],
|
||||
'allTags' => $data['allTags'],
|
||||
'latestJob' => $data['latestJob'],
|
||||
|
||||
'statusRunning' => TagRebuildJob::STATUS_RUNNING,
|
||||
'statusQueued' => TagRebuildJob::STATUS_QUEUED,
|
||||
'statusCompleted' => TagRebuildJob::STATUS_COMPLETED,
|
||||
'statusFailed' => TagRebuildJob::STATUS_FAILED,
|
||||
...$data,
|
||||
...$this->buildJobStatusViewData(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/tags/save', name: 'admin_document_tags_save', methods: ['POST'])]
|
||||
public function save(string $id, Request $request, DocumentTagAdminService $svc): RedirectResponse
|
||||
{
|
||||
$selected = $request->request->all('tag_ids') ?? [];
|
||||
$id = trim($id);
|
||||
|
||||
if (!$this->isCsrfTokenValid('admin_document_tags_save_' . $id, (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
return $this->redirectToRoute('admin_document_tags_edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
try {
|
||||
$svc->saveTags($id, $selected);
|
||||
$svc->saveTags($id, $this->normalizeStringList($request->request->all('tag_ids')));
|
||||
$this->addFlash('success', 'Tags wurden aktualisiert. Rebuild läuft im Hintergrund.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', $e->getMessage());
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Tags konnten nicht aktualisiert werden.'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_document_tags_edit', ['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wichtig: Ohne extra "admin/" im Pfad, weil Prefix schon /admin/documents ist.
|
||||
* Ergebnis: /admin/documents/tags/status
|
||||
*/
|
||||
#[Route('/tags/status', name: 'admin_tags_status', methods: ['GET'])]
|
||||
public function status(DocumentTagAdminService $svc): JsonResponse
|
||||
{
|
||||
$status = $svc->getLatestRebuildStatus();
|
||||
|
||||
return $this->json([
|
||||
'status' => $svc->getLatestRebuildStatus(),
|
||||
'status' => $status,
|
||||
'hasActiveJob' => $status === TagRebuildJob::STATUS_RUNNING
|
||||
|| $status === TagRebuildJob::STATUS_QUEUED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeStringList(mixed $values): array
|
||||
{
|
||||
if (!is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $value;
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildJobStatusViewData(): array
|
||||
{
|
||||
return [
|
||||
'statusRunning' => TagRebuildJob::STATUS_RUNNING,
|
||||
'statusQueued' => TagRebuildJob::STATUS_QUEUED,
|
||||
'statusCompleted' => TagRebuildJob::STATUS_COMPLETED,
|
||||
'statusFailed' => TagRebuildJob::STATUS_FAILED,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildSafeErrorMessage(\Throwable $e, string $fallback): string
|
||||
{
|
||||
$message = trim($e->getMessage());
|
||||
|
||||
return $message !== '' ? $message : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\IngestJob;
|
||||
use App\Service\IngestJobService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
|
||||
#[Route('/admin/jobs')]
|
||||
class IngestJobController extends AbstractController
|
||||
final class IngestJobController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'admin_jobs')]
|
||||
#[Route('', name: 'admin_jobs', methods: ['GET'])]
|
||||
public function index(EntityManagerInterface $em): Response
|
||||
{
|
||||
$jobs = $em->getRepository(IngestJob::class)
|
||||
->findBy([], ['startedAt' => 'DESC']);
|
||||
->findBy([], ['startedAt' => 'DESC', 'id' => 'DESC']);
|
||||
|
||||
return $this->render('admin/job/index.html.twig', [
|
||||
'jobs' => $jobs
|
||||
'jobs' => $jobs,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(
|
||||
'/{id}',
|
||||
name: 'admin_job_show',
|
||||
requirements: ['id' => '[0-9a-fA-F\-]{36}']
|
||||
requirements: ['id' => '[0-9a-fA-F\-]{36}'],
|
||||
methods: ['GET']
|
||||
)]
|
||||
public function show(string $id, EntityManagerInterface $em): Response
|
||||
{
|
||||
$job = $em->getRepository(IngestJob::class)->find($id);
|
||||
|
||||
if (!$job) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return $this->render('admin/job/show.html.twig', [
|
||||
'job' => $job
|
||||
'job' => $this->findJob($id, $em),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -54,12 +52,7 @@ class IngestJobController extends AbstractController
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_USER');
|
||||
|
||||
/** @var IngestJob|null $job */
|
||||
$job = $em->getRepository(IngestJob::class)->find($id);
|
||||
|
||||
if (!$job) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
$job = $this->findJob($id, $em);
|
||||
|
||||
return $this->json([
|
||||
'id' => (string) $job->getId(),
|
||||
@@ -68,58 +61,185 @@ class IngestJobController extends AbstractController
|
||||
'startedAt' => $job->getStartedAt()->format(DATE_ATOM),
|
||||
'finishedAt' => $job->getFinishedAt()?->format(DATE_ATOM),
|
||||
'errorMessage' => $job->getErrorMessage(),
|
||||
'logPath' => $job->getLogPath(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/global-reindex', name: 'admin_global_reindex', methods: ['POST'])]
|
||||
public function globalReindex(
|
||||
Request $request,
|
||||
IngestJobService $jobService,
|
||||
EntityManagerInterface $em,
|
||||
): RedirectResponse {
|
||||
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 1) Job anlegen (QUEUED)
|
||||
// ---------------------------------------------------------
|
||||
$job = $jobService->startJob(
|
||||
IngestJob::TYPE_GLOBAL_REINDEX,
|
||||
$this->getUser(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
IngestJob::STATUS_QUEUED
|
||||
);
|
||||
if (!$this->isCsrfTokenValid('global_reindex', (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2) CLI im Hintergrund starten
|
||||
// ---------------------------------------------------------
|
||||
$projectDir = (string)$this->getParameter('kernel.project_dir');
|
||||
$console = $projectDir . '/bin/console';
|
||||
|
||||
$logDir = $projectDir . '/var/log/ingest';
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0777, true);
|
||||
return $this->redirectToRoute('admin_jobs');
|
||||
}
|
||||
$logFile = $logDir . '/job_' . (string)$job->getId() . '.log';
|
||||
|
||||
$php = 'php';
|
||||
try {
|
||||
$projectDir = $this->resolveProjectDir();
|
||||
$console = $projectDir . '/bin/console';
|
||||
|
||||
$cmd = sprintf(
|
||||
'%s %s --no-interaction %s %s >> %s 2>&1 &',
|
||||
escapeshellcmd($php),
|
||||
escapeshellarg($console),
|
||||
escapeshellarg('mto:agent:ingest:run'),
|
||||
escapeshellarg((string)$job->getId()),
|
||||
escapeshellarg($logFile),
|
||||
);
|
||||
if (!is_file($console)) {
|
||||
throw new \RuntimeException('bin/console not found: ' . $console);
|
||||
}
|
||||
|
||||
exec($cmd);
|
||||
$logDir = $projectDir . '/var/log/ingest';
|
||||
$this->ensureDirectoryExists($logDir);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3) Redirect auf Job-Detailseite (Loader)
|
||||
// ---------------------------------------------------------
|
||||
return $this->redirectToRoute('admin_job_show', [
|
||||
'id' => (string)$job->getId(),
|
||||
]);
|
||||
$job = $jobService->startJob(
|
||||
IngestJob::TYPE_GLOBAL_REINDEX,
|
||||
$this->getUser(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
IngestJob::STATUS_QUEUED
|
||||
);
|
||||
|
||||
$logFile = $logDir . '/job_' . (string) $job->getId() . '.log';
|
||||
$job->setLogPath($logFile);
|
||||
$em->flush();
|
||||
|
||||
$phpBinary = $this->resolvePhpBinary();
|
||||
$cmd = sprintf(
|
||||
'cd %s && nohup %s %s %s %s --no-interaction >> %s 2>&1 & echo $!',
|
||||
escapeshellarg($projectDir),
|
||||
escapeshellarg($phpBinary),
|
||||
escapeshellarg($console),
|
||||
escapeshellarg('mto:agent:ingest:run'),
|
||||
escapeshellarg((string) $job->getId()),
|
||||
escapeshellarg($logFile),
|
||||
);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
@exec($cmd, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$job->markFailed('Global reindex async bootstrap failed with exit code ' . $exitCode . '.');
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('danger', 'Global Reindex konnte nicht im Hintergrund gestartet werden.');
|
||||
|
||||
return $this->redirectToRoute('admin_job_show', [
|
||||
'id' => (string) $job->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Global Reindex wurde gestartet.');
|
||||
|
||||
return $this->redirectToRoute('admin_job_show', [
|
||||
'id' => (string) $job->getId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Global Reindex konnte nicht gestartet werden.'));
|
||||
|
||||
return $this->redirectToRoute('admin_jobs');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function findJob(string $id, EntityManagerInterface $em): IngestJob
|
||||
{
|
||||
$id = trim($id);
|
||||
|
||||
/** @var IngestJob|null $job */
|
||||
$job = $em->getRepository(IngestJob::class)->find($id);
|
||||
|
||||
if (!$job instanceof IngestJob) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
private function resolveProjectDir(): string
|
||||
{
|
||||
$projectDir = trim((string) $this->getParameter('kernel.project_dir'));
|
||||
|
||||
if ($projectDir === '' || !is_dir($projectDir)) {
|
||||
throw new \RuntimeException('Project directory is invalid.');
|
||||
}
|
||||
|
||||
return rtrim($projectDir, '/');
|
||||
}
|
||||
|
||||
private function resolvePhpBinary(): string
|
||||
{
|
||||
$envCandidates = [
|
||||
trim((string) ($_SERVER['PHP_CLI_BINARY'] ?? '')),
|
||||
trim((string) ($_ENV['PHP_CLI_BINARY'] ?? '')),
|
||||
trim((string) getenv('PHP_CLI_BINARY')),
|
||||
];
|
||||
|
||||
foreach ($envCandidates as $candidate) {
|
||||
if ($this->isValidCliPhpBinary($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$phpBinary = defined('PHP_BINARY') ? trim((string) PHP_BINARY) : '';
|
||||
if ($this->isValidCliPhpBinary($phpBinary)) {
|
||||
return $phpBinary;
|
||||
}
|
||||
|
||||
$fallbackCandidates = [
|
||||
'/usr/bin/php',
|
||||
'/usr/local/bin/php',
|
||||
'/bin/php',
|
||||
'/opt/homebrew/bin/php',
|
||||
];
|
||||
|
||||
foreach ($fallbackCandidates as $candidate) {
|
||||
if ($this->isValidCliPhpBinary($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$whichPhp = trim((string) @shell_exec('command -v php 2>/dev/null'));
|
||||
if ($this->isValidCliPhpBinary($whichPhp)) {
|
||||
return $whichPhp;
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
'Could not resolve a CLI PHP binary. Set PHP_CLI_BINARY explicitly, e.g. /usr/bin/php.'
|
||||
);
|
||||
}
|
||||
|
||||
private function isValidCliPhpBinary(string $path): bool
|
||||
{
|
||||
$path = trim($path);
|
||||
|
||||
if ($path === '' || !is_file($path) || !is_executable($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$basename = strtolower(basename($path));
|
||||
|
||||
if (str_contains($basename, 'fpm') || str_contains($basename, 'cgi')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function ensureDirectoryExists(string $dir): void
|
||||
{
|
||||
if (is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new \RuntimeException('Could not create ingest log directory.');
|
||||
}
|
||||
}
|
||||
|
||||
private function buildSafeErrorMessage(\Throwable $e, string $fallback): string
|
||||
{
|
||||
$message = trim($e->getMessage());
|
||||
|
||||
return $message !== '' ? $message : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\TagRebuildJob;
|
||||
use App\Service\Admin\TagAdminService;
|
||||
use App\Tag\TagTypes;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -18,41 +19,32 @@ final class TagController extends AbstractController
|
||||
#[Route('', name: 'admin_tags_index', methods: ['GET'])]
|
||||
public function index(TagAdminService $svc): Response
|
||||
{
|
||||
$data = $svc->getIndexData();
|
||||
|
||||
return $this->render('admin/tag/index.html.twig', [
|
||||
...$data,
|
||||
'statusRunning' => TagRebuildJob::STATUS_RUNNING,
|
||||
'statusQueued' => TagRebuildJob::STATUS_QUEUED,
|
||||
'statusCompleted' => TagRebuildJob::STATUS_COMPLETED,
|
||||
'statusFailed' => TagRebuildJob::STATUS_FAILED,
|
||||
...$svc->getIndexData(),
|
||||
...$this->buildJobStatusViewData(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/create', name: 'admin_tags_create', methods: ['POST'])]
|
||||
public function create(Request $request, TagAdminService $svc): RedirectResponse
|
||||
{
|
||||
if (!$this->isCsrfTokenValid(
|
||||
'admin_tag_create',
|
||||
$request->request->get('_token')
|
||||
)) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF Token.');
|
||||
if (!$this->isCsrfTokenValid('admin_tag_create', (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
return $this->redirectToRoute('admin_tags_index');
|
||||
}
|
||||
|
||||
try {
|
||||
$svc->create(
|
||||
(string)$request->request->get('slug', ''),
|
||||
(string)$request->request->get('label', ''),
|
||||
$request->request->get('description')
|
||||
? (string)$request->request->get('description')
|
||||
: null,
|
||||
(string)$request->request->get('type', 'generic') // NEU
|
||||
(string) $request->request->get('slug', ''),
|
||||
(string) $request->request->get('label', ''),
|
||||
$this->normalizeNullableString($request->request->get('description')),
|
||||
TagTypes::normalize((string) $request->request->get('type', TagTypes::GENERIC))
|
||||
);
|
||||
|
||||
$this->addFlash('success', 'Tag wurde erstellt.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', $e->getMessage());
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Tag konnte nicht erstellt werden.'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_tags_index');
|
||||
@@ -61,58 +53,110 @@ final class TagController extends AbstractController
|
||||
#[Route('/{id}/delete', name: 'admin_tags_delete', methods: ['POST'])]
|
||||
public function delete(string $id, Request $request, TagAdminService $svc): RedirectResponse
|
||||
{
|
||||
if (!$this->isCsrfTokenValid(
|
||||
'admin_tag_delete_' . $id,
|
||||
$request->request->get('_token')
|
||||
)) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF Token.');
|
||||
if (!$this->isCsrfTokenValid('admin_tag_delete_' . $id, (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
return $this->redirectToRoute('admin_tags_index');
|
||||
}
|
||||
|
||||
try {
|
||||
$svc->delete($id);
|
||||
$svc->delete(trim($id));
|
||||
$this->addFlash('success', 'Tag wurde gelöscht.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', $e->getMessage());
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Tag konnte nicht gelöscht werden.'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_tags_index');
|
||||
}
|
||||
|
||||
#[Route('/{id}/assign', name: 'admin_tags_assign', methods: ['GET', 'POST'])]
|
||||
public function assign(
|
||||
string $id,
|
||||
Request $request,
|
||||
TagAdminService $svc
|
||||
): Response {
|
||||
public function assign(string $id, Request $request, TagAdminService $svc): Response
|
||||
{
|
||||
$id = trim($id);
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
if (!$this->isCsrfTokenValid('assign_tag_' . $id, (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
if (!$this->isCsrfTokenValid(
|
||||
'assign_tag_' . $id,
|
||||
$request->request->get('_token')
|
||||
)) {
|
||||
throw $this->createAccessDeniedException();
|
||||
return $this->redirectToRoute('admin_tags_assign', ['id' => $id]);
|
||||
}
|
||||
|
||||
$svc->syncAssignments(
|
||||
$id,
|
||||
$request->request->all('documents') ?? []
|
||||
);
|
||||
|
||||
$this->addFlash('success', 'Zuweisungen aktualisiert.');
|
||||
try {
|
||||
$svc->syncAssignments($id, $this->normalizeStringList($request->request->all('documents')));
|
||||
$this->addFlash('success', 'Zuweisungen aktualisiert.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Zuweisungen konnten nicht aktualisiert werden.'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_tags_assign', ['id' => $id]);
|
||||
}
|
||||
|
||||
$data = $svc->getAssignData($id);
|
||||
try {
|
||||
$data = $svc->getAssignData($id);
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', $this->buildSafeErrorMessage($e, 'Tag konnte nicht geladen werden.'));
|
||||
|
||||
return $this->redirectToRoute('admin_tags_index');
|
||||
}
|
||||
|
||||
return $this->render('admin/tag/assign.html.twig', [
|
||||
...$data,
|
||||
...$this->buildJobStatusViewData(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
private function normalizeNullableString(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeStringList(mixed $values): array
|
||||
{
|
||||
if (!is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $value;
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildJobStatusViewData(): array
|
||||
{
|
||||
return [
|
||||
'statusRunning' => TagRebuildJob::STATUS_RUNNING,
|
||||
'statusQueued' => TagRebuildJob::STATUS_QUEUED,
|
||||
'statusCompleted' => TagRebuildJob::STATUS_COMPLETED,
|
||||
'statusFailed' => TagRebuildJob::STATUS_FAILED,
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
private function buildSafeErrorMessage(\Throwable $e, string $fallback): string
|
||||
{
|
||||
$message = trim($e->getMessage());
|
||||
|
||||
return $message !== '' ? $message : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -10,38 +10,79 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class TagRebuildStreamController
|
||||
{
|
||||
#[Route('/admin/tags/rebuild/stream', name: 'admin_tags_rebuild_stream')]
|
||||
private const POLL_INTERVAL_SECONDS = 2;
|
||||
private const KEEPALIVE_INTERVAL_SECONDS = 10;
|
||||
|
||||
#[Route('/admin/tags/rebuild/stream', name: 'admin_tags_rebuild_stream', methods: ['GET'])]
|
||||
public function stream(TagRebuildStatusProvider $provider): StreamedResponse
|
||||
{
|
||||
$response = new StreamedResponse(function () use ($provider) {
|
||||
$response = new StreamedResponse(function () use ($provider): void {
|
||||
self::disableOutputBuffering();
|
||||
|
||||
echo "event: ping\n";
|
||||
echo "data: " . json_encode(['init' => true]) . "\n\n";
|
||||
echo "retry: 3000\n";
|
||||
self::sendEvent('ping', ['init' => true]);
|
||||
|
||||
@ob_flush();
|
||||
@flush();
|
||||
$lastPayloadHash = null;
|
||||
$lastKeepaliveAt = time();
|
||||
|
||||
while (!connection_aborted()) {
|
||||
|
||||
$data = $provider->getLatestStatus();
|
||||
|
||||
if ($data !== null) {
|
||||
echo "event: message\n";
|
||||
echo "data: " . json_encode($data) . "\n\n";
|
||||
$payloadHash = md5(
|
||||
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'null'
|
||||
);
|
||||
|
||||
@ob_flush();
|
||||
@flush();
|
||||
if ($payloadHash !== $lastPayloadHash) {
|
||||
self::sendEvent('message', $data);
|
||||
$lastPayloadHash = $payloadHash;
|
||||
$lastKeepaliveAt = time();
|
||||
}
|
||||
}
|
||||
|
||||
sleep(2);
|
||||
if ((time() - $lastKeepaliveAt) >= self::KEEPALIVE_INTERVAL_SECONDS) {
|
||||
self::sendEvent('ping', [
|
||||
'ts' => (new \DateTimeImmutable())->format(DATE_ATOM),
|
||||
]);
|
||||
$lastKeepaliveAt = time();
|
||||
}
|
||||
|
||||
sleep(self::POLL_INTERVAL_SECONDS);
|
||||
}
|
||||
});
|
||||
|
||||
$response->headers->set('Content-Type', 'text/event-stream');
|
||||
$response->headers->set('Cache-Control', 'no-cache');
|
||||
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
$response->headers->set('Pragma', 'no-cache');
|
||||
$response->headers->set('Expires', '0');
|
||||
$response->headers->set('Connection', 'keep-alive');
|
||||
$response->headers->set('X-Accel-Buffering', 'no');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private static function disableOutputBuffering(): void
|
||||
{
|
||||
while (ob_get_level() > 0) {
|
||||
@ob_end_flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function sendEvent(string $event, array $data): void
|
||||
{
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if (!is_string($json)) {
|
||||
$json = '{"error":"json_encode_failed"}';
|
||||
}
|
||||
|
||||
echo 'event: ' . $event . "\n";
|
||||
echo 'data: ' . $json . "\n\n";
|
||||
|
||||
@ob_flush();
|
||||
@flush();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user