first commit

This commit is contained in:
team 1
2026-04-20 16:36:28 +02:00
parent a0ec07a99c
commit 2587ac8b4b
41 changed files with 5126 additions and 2280 deletions

View File

@@ -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(),
]);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}