feat: camera capture and multi-photo upload at article ingest

- Ingest page redesigned: camera modal (getUserMedia + capture fallback
  for mobile), mandatory search photo with client-side validation,
  optional extra photos grid with per-photo remove button
- Camera modal: live video preview, capture → preview → confirm/retake
  flow; stops stream on modal close; falls back to native file picker
  if getUserMedia is unavailable
- ManualIngestController: uploads extra photos via PhotoService::uploadRaw(),
  stores [{storagePathId, filename}] in job inputData as extraPhotos
- PhotoService::attachExtra(): attach already-stored file to an article
  by StoragePath ID + filename
- DraftArticleHandler: after creating the article, attaches extra photos
  in sort order; errors are best-effort (pipeline not aborted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 09:16:04 +00:00
parent 693e458e07
commit 6241398390
4 changed files with 407 additions and 103 deletions

View file

@ -9,6 +9,7 @@ use App\Application\Storage\StoredFile;
use App\Domain\Article\ArticlePhoto;
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Storage\Repository\StoragePathRepositoryInterface;
use Symfony\Component\Uid\Uuid;
final class PhotoService
@ -17,6 +18,7 @@ final class PhotoService
private readonly ArticleRepositoryInterface $articleRepository,
private readonly ArticlePhotoRepositoryInterface $photoRepository,
private readonly StorageManagerInterface $storageManager,
private readonly StoragePathRepositoryInterface $storagePathRepository,
) {
}
@ -58,6 +60,23 @@ final class PhotoService
/**
* @param list<string> $orderedPhotoIds UUIDs in the desired display order
*/
/**
* Attach a file that was already stored (e.g. uploaded at ingest time) to an article.
*
* @param string $storagePathId UUID of the StoragePath entity
*/
public function attachExtra(Uuid $articleId, string $storagePathId, string $filename, int $sortOrder): void
{
$article = $this->articleRepository->findById($articleId)
?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");
$storagePath = $this->storagePathRepository->findById(Uuid::fromString($storagePathId))
?? throw new \DomainException("StoragePath {$storagePathId} not found");
$photo = new ArticlePhoto($article, $storagePath, $filename, false, $sortOrder);
$this->photoRepository->save($photo);
}
public function reorder(Uuid $articleId, array $orderedPhotoIds): void
{
$photos = $this->photoRepository->findByArticle($articleId);

View file

@ -57,6 +57,28 @@ final class ManualIngestController extends AbstractController
$storedPath = $stored->storagePath->resolveFilePath($stored->filename);
// Upload any extra photos and store references for DraftArticleHandler
$extraPhotos = [];
/** @var UploadedFile[] $additionalFiles */
$additionalFiles = array_filter(
(array) $request->files->get('additional_photos', []),
static fn ($f) => $f instanceof UploadedFile,
);
foreach ($additionalFiles as $extra) {
try {
$extraStored = $this->photoService->uploadRaw(
$extra->getRealPath(),
$extra->getClientOriginalName(),
);
$extraPhotos[] = [
'storagePathId' => $extraStored->storagePath->getId()->toRfc4122(),
'filename' => $extraStored->filename,
];
} catch (\RuntimeException) {
// skip photos that fail to store — don't abort the whole job
}
}
$job = new AIPipelineJob(AIPipelineJobType::Photo, [
'inventoryNumber' => $catalogNumber,
'articleTypeId' => $articleType->getId()->toRfc4122(),
@ -64,6 +86,7 @@ final class ManualIngestController extends AbstractController
'conditionNotes' => $form->get('conditionNotes')->getData(),
'originalFilename' => $image->getClientOriginalName(),
'storedPhotoPath' => $storedPath,
'extraPhotos' => $extraPhotos,
]);
$this->jobRepository->save($job);

View file

@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Application\Article\ArticleService;
use App\Application\Article\PhotoService;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleStatus;
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\Messenger\Message\DraftArticleMessage;
@ -23,6 +25,8 @@ final class DraftArticleHandler
private readonly ArticleRepositoryInterface $articleRepository,
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly MessageBusInterface $bus,
private readonly PhotoService $photoService,
private readonly ArticlePhotoRepositoryInterface $photoRepository,
) {
}
@ -80,6 +84,21 @@ final class DraftArticleHandler
$job->markCompleted(['articleId' => $article->getId()->toRfc4122()]);
$this->jobRepository->save($job);
// Attach any extra photos uploaded during ingest
$existingCount = \count($this->photoRepository->findByArticle($article->getId()));
foreach ($job->getInputData()['extraPhotos'] ?? [] as $i => $photoData) {
try {
$this->photoService->attachExtra(
$article->getId(),
(string) $photoData['storagePathId'],
(string) $photoData['filename'],
$existingCount + $i,
);
} catch (\DomainException) {
// best-effort — don't fail the whole pipeline for a missing extra photo
}
}
$this->bus->dispatch(new EbayTextMessage(
jobId: $message->jobId,
articleId: $article->getId()->toRfc4122(),

View file

@ -1,6 +1,6 @@
{% extends '@EasyAdmin/page/content.html.twig' %}
{% block page_title %}Ingest Article{% endblock %}
{% block page_title %}Artikel einlesen{% endblock %}
{% block main %}
@ -8,109 +8,193 @@
<div class="alert alert-success d-flex align-items-center gap-3 mb-4" role="alert">
<i class="fa fa-check-circle fa-2x"></i>
<div>
<strong>Queued — catalog number:</strong>
<strong>In der Pipeline —</strong>
<span class="fs-4 ms-2 font-monospace fw-bold">{{ catalogNumber }}</span>
<div class="small mt-1">
Pipeline running.
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\AIPipelineJobCrudController').setAction('index').generateUrl() }}">Check job status →</a>
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\AIPipelineJobCrudController').setAction('index').generateUrl() }}">Job-Status ansehen →</a>
</div>
</div>
</div>
{% endif %}
{# ── Camera Modal ─────────────────────────────────────────────────── #}
<div class="modal fade" id="cameraModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cameraModalLabel"><i class="fa fa-camera me-2"></i>Foto aufnehmen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 bg-black text-center" style="min-height:300px;display:flex;align-items:center;justify-content:center;">
<video id="camera-video" autoplay playsinline muted
style="max-width:100%;max-height:420px;display:block;"></video>
<canvas id="camera-canvas" style="display:none;max-width:100%;max-height:420px;"></canvas>
<div id="camera-error" class="text-white p-4" style="display:none;">
<i class="fa fa-exclamation-circle fa-2x mb-2 d-block text-warning"></i>
Kein Kamerazugriff. Bitte Datei hochladen.
</div>
</div>
<div class="modal-footer justify-content-center gap-2">
<button id="camera-capture-btn" class="btn btn-primary btn-lg" style="display:none;">
<i class="fa fa-circle me-1" style="color:#e74c3c;"></i> Aufnehmen
</button>
<button id="camera-retake-btn" class="btn btn-secondary" style="display:none;">
<i class="fa fa-redo me-1"></i> Wiederholen
</button>
<button id="camera-confirm-btn" class="btn btn-success btn-lg" style="display:none;">
<i class="fa fa-check me-1"></i> Verwenden
</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
</div>
</div>
</div>
</div>
<div class="row g-4">
{# ── Left: ingest form ─────────────────────────────────── #}
{# ── Left: form ───────────────────────────────────────────────── #}
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0"><i class="fa fa-camera me-2"></i>Scan Nameplate</h5>
</div>
<div class="card-body">
{{ form_start(form, {'attr': {'enctype': 'multipart/form-data', 'novalidate': 'novalidate'}}) }}
{{ form_start(form, {'attr': {'enctype': 'multipart/form-data', 'id': 'ingest-form', 'novalidate': 'novalidate'}}) }}
<div class="card mb-3">
<div class="card-header"><h5 class="mb-0"><i class="fa fa-tag me-2"></i>Artikelinfo</h5></div>
<div class="card-body">
<div class="mb-3">
{{ form_label(form.articleType) }}
{{ form_widget(form.articleType) }}
{{ form_errors(form.articleType) }}
</div>
<div class="mb-3">
<div class="row g-3">
<div class="col-sm-6">
{{ form_label(form.condition) }}
{{ form_widget(form.condition) }}
{{ form_errors(form.condition) }}
</div>
<div class="mb-3">
{{ form_label(form.image) }}
<div id="image-preview-wrapper" class="mb-2" style="display:none">
<img id="image-preview" src="" alt="Preview" class="img-thumbnail" style="max-height:220px">
</div>
{{ form_widget(form.image, {'attr': {'class': 'form-control', 'id': 'ingest-image-input'}}) }}
{{ form_errors(form.image) }}
</div>
<div class="mb-3">
<div class="col-sm-6">
{{ form_label(form.conditionNotes) }}
{{ form_widget(form.conditionNotes, {'attr': {'class': 'form-control'}}) }}
{{ form_errors(form.conditionNotes) }}
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="fa fa-rocket me-2"></i>Submit to AI Pipeline
{# ── Search photo (mandatory) ─────────────────────────────── #}
<div class="card mb-3 border-primary">
<div class="card-header bg-primary bg-opacity-10">
<h5 class="mb-0 text-primary">
<i class="fa fa-search me-2"></i>Erkennungs-Foto
<span class="badge bg-danger ms-2 fs-6">Pflicht</span>
</h5>
<div class="text-muted small mt-1">Typenschild / Aufkleber mit Modell &amp; Seriennummer</div>
</div>
<div class="card-body">
{# Hidden actual file input #}
{{ form_widget(form.image, {'attr': {'class': 'd-none', 'id': 'search-photo-input'}}) }}
{{ form_errors(form.image) }}
{# Preview #}
<div id="search-photo-preview" class="mb-3 text-center" style="display:none;">
<img id="search-photo-img" src="" alt="Vorschau"
class="img-fluid rounded border" style="max-height:220px;">
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="search-photo-clear">
<i class="fa fa-times me-1"></i>Entfernen
</button>
</div>
</div>
{# Placeholder when empty #}
<div id="search-photo-placeholder" class="border rounded text-center p-4 text-muted bg-light">
<i class="fa fa-image fa-3x mb-3 d-block"></i>
Noch kein Foto ausgewählt
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-primary flex-fill" id="btn-search-camera">
<i class="fa fa-camera me-2"></i>Kamera
</button>
<button type="button" class="btn btn-outline-secondary flex-fill" id="btn-search-file">
<i class="fa fa-folder-open me-2"></i>Datei wählen
</button>
</div>
</div>
</div>
{# ── Additional photos (optional) ─────────────────────────── #}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="fa fa-images me-2"></i>Weitere Fotos <span class="text-muted fw-normal fs-6">(optional)</span></h5>
</div>
<div class="card-body">
{# Hidden input that holds extra files #}
<input type="file" name="additional_photos[]" id="extra-photos-input"
accept="image/jpeg,image/png,image/webp" multiple class="d-none">
<div id="extra-photos-grid" class="d-flex flex-wrap gap-2 mb-3" style="min-height:60px;">
<div id="extra-photos-empty" class="text-muted small fst-italic d-flex align-items-center">
Keine weiteren Fotos hinzugefügt
</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary flex-fill" id="btn-extra-camera">
<i class="fa fa-camera me-2"></i>Kamera
</button>
<button type="button" class="btn btn-outline-secondary flex-fill" id="btn-extra-file">
<i class="fa fa-folder-open me-2"></i>Datei wählen
</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-success btn-lg w-100" id="submit-btn">
<i class="fa fa-rocket me-2"></i>Zur KI-Pipeline senden
</button>
{{ form_end(form) }}
</div>
</div>
</div>
{# ── Right: AI config panel ────────────────────────────── #}
{# ── Right: AI config ─────────────────────────────────────────── #}
<div class="col-lg-5">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><i class="fa fa-robot me-2"></i>AI Configuration</h5>
<h5 class="card-title mb-0"><i class="fa fa-robot me-2"></i>KI-Konfiguration</h5>
<span class="badge bg-{{ aiConfig.backend == 'Mistral' ? 'primary' : 'secondary' }}">
{{ aiConfig.backend }} active
{{ aiConfig.backend }} aktiv
</span>
</div>
<div class="card-body p-0">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<th class="ps-3 text-muted fw-normal" style="width:45%">Vision model</th>
<th class="ps-3 text-muted fw-normal" style="width:45%">Vision-Modell</th>
<td class="font-monospace">{{ aiConfig.vision_model }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Text model</th>
<th class="ps-3 text-muted fw-normal">Text-Modell</th>
<td class="font-monospace">{{ aiConfig.text_model }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Mistral endpoint</th>
<th class="ps-3 text-muted fw-normal">Mistral Endpoint</th>
<td class="font-monospace small">{{ aiConfig.mistral_base_url }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Mistral API key</th>
<th class="ps-3 text-muted fw-normal">Mistral API-Key</th>
<td>
{% if aiConfig.mistral_key_set %}
<span class="badge bg-success me-1">set</span>
<span class="badge bg-success me-1">gesetzt</span>
<span class="font-monospace small text-muted">{{ aiConfig.mistral_key_hint }}</span>
{% else %}
<span class="badge bg-danger">not set</span>
<span class="badge bg-danger">nicht gesetzt</span>
{% endif %}
</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Ollama endpoint</th>
<td class="font-monospace small">{{ aiConfig.ollama_base_url }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card-footer">
<button id="btn-test-mistral" class="btn btn-outline-primary btn-sm w-100"
{% if not aiConfig.mistral_key_set %}disabled title="Set MISTRAL_API_KEY first"{% endif %}>
<i class="fa fa-plug me-1"></i>Test Mistral Connection
{% if not aiConfig.mistral_key_set %}disabled title="MISTRAL_API_KEY setzen"{% endif %}>
<i class="fa fa-plug me-1"></i>Mistral-Verbindung testen
</button>
<div id="test-result" class="mt-3" style="display:none"></div>
</div>
@ -120,68 +204,227 @@
</div>
<script>
document.getElementById('ingest-image-input')?.addEventListener('change', function () {
const file = this.files[0];
if (!file) return;
const wrapper = document.getElementById('image-preview-wrapper');
const img = document.getElementById('image-preview');
img.src = URL.createObjectURL(file);
wrapper.style.display = 'block';
(function () {
/* ── Camera state ──────────────────────────────────────────── */
const video = document.getElementById('camera-video');
const canvas = document.getElementById('camera-canvas');
const errDiv = document.getElementById('camera-error');
const btnCapture = document.getElementById('camera-capture-btn');
const btnRetake = document.getElementById('camera-retake-btn');
const btnConfirm = document.getElementById('camera-confirm-btn');
let cameraStream = null;
let cameraTarget = null; // 'search' | 'extra'
let capturedBlob = null;
const modal = new bootstrap.Modal(document.getElementById('cameraModal'));
async function openCamera(target) {
cameraTarget = target;
capturedBlob = null;
canvas.style.display = 'none';
errDiv.style.display = 'none';
video.style.display = 'block';
btnCapture.style.display = 'none';
btnRetake.style.display = 'none';
btnConfirm.style.display = 'none';
modal.show();
try {
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 } },
});
video.srcObject = cameraStream;
await video.play();
btnCapture.style.display = 'inline-block';
} catch {
stopCamera();
video.style.display = 'none';
errDiv.style.display = 'block';
// Mobile fallback: trigger native camera input
if (cameraTarget === 'search') {
document.getElementById('search-photo-input').click();
} else {
document.getElementById('extra-photos-input').click();
}
modal.hide();
}
}
function stopCamera() {
cameraStream?.getTracks().forEach(t => t.stop());
cameraStream = null;
}
document.getElementById('cameraModal').addEventListener('hidden.bs.modal', stopCamera);
btnCapture.addEventListener('click', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
stopCamera();
video.style.display = 'none';
canvas.style.display = 'block';
btnCapture.style.display = 'none';
btnRetake.style.display = 'inline-block';
btnConfirm.style.display = 'inline-block';
canvas.toBlob(b => { capturedBlob = b; }, 'image/jpeg', 0.92);
});
btnRetake.addEventListener('click', () => openCamera(cameraTarget));
btnConfirm.addEventListener('click', () => {
if (!capturedBlob) return;
const file = new File([capturedBlob], 'kamera-' + Date.now() + '.jpg', { type: 'image/jpeg' });
if (cameraTarget === 'search') {
setSearchPhoto(file);
} else {
addExtraPhoto(file);
}
modal.hide();
});
/* ── Search photo ──────────────────────────────────────────── */
const searchInput = document.getElementById('search-photo-input');
const searchPreview = document.getElementById('search-photo-preview');
const searchImg = document.getElementById('search-photo-img');
const searchHolder = document.getElementById('search-photo-placeholder');
document.getElementById('btn-search-camera').addEventListener('click', () => openCamera('search'));
document.getElementById('btn-search-file').addEventListener('click', () => searchInput.click());
document.getElementById('search-photo-clear').addEventListener('click', () => {
searchInput.value = '';
searchImg.src = '';
searchPreview.style.display = 'none';
searchHolder.style.display = 'block';
});
searchInput.addEventListener('change', () => {
if (searchInput.files[0]) setSearchPhoto(searchInput.files[0]);
});
function setSearchPhoto(file) {
const dt = new DataTransfer();
dt.items.add(file);
searchInput.files = dt.files;
searchImg.src = URL.createObjectURL(file);
searchPreview.style.display = 'block';
searchHolder.style.display = 'none';
}
/* ── Extra photos ──────────────────────────────────────────── */
const extraInput = document.getElementById('extra-photos-input');
const extraGrid = document.getElementById('extra-photos-grid');
const extraEmpty = document.getElementById('extra-photos-empty');
let extraFiles = [];
document.getElementById('btn-extra-camera').addEventListener('click', () => openCamera('extra'));
document.getElementById('btn-extra-file').addEventListener('click', () => extraInput.click());
extraInput.addEventListener('change', () => {
for (const f of extraInput.files) addExtraPhoto(f);
extraInput.value = '';
});
function addExtraPhoto(file) {
extraFiles.push(file);
syncExtraInput();
renderExtraGrid();
}
function removeExtraPhoto(idx) {
extraFiles.splice(idx, 1);
syncExtraInput();
renderExtraGrid();
}
function syncExtraInput() {
const dt = new DataTransfer();
extraFiles.forEach(f => dt.items.add(f));
extraInput.files = dt.files;
}
function renderExtraGrid() {
// remove all thumbnail cards (keep the empty placeholder)
extraGrid.querySelectorAll('.extra-thumb').forEach(el => el.remove());
extraEmpty.style.display = extraFiles.length === 0 ? 'flex' : 'none';
extraFiles.forEach((file, idx) => {
const card = document.createElement('div');
card.className = 'extra-thumb position-relative';
card.style.cssText = 'width:90px;height:90px;';
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.className = 'w-100 h-100 rounded border object-fit-cover';
img.style.objectFit = 'cover';
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-danger btn-sm position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
del.style.cssText = 'width:20px;height:20px;font-size:.65rem;line-height:1;border-radius:50%;transform:translate(40%,-40%);';
del.innerHTML = '×';
del.addEventListener('click', () => removeExtraPhoto(idx));
card.appendChild(img);
card.appendChild(del);
extraGrid.appendChild(card);
});
}
/* ── Submit validation ─────────────────────────────────────── */
document.getElementById('ingest-form').addEventListener('submit', e => {
if (!searchInput.files || searchInput.files.length === 0) {
e.preventDefault();
searchHolder.classList.add('border-danger');
searchHolder.innerHTML = '<i class="fa fa-exclamation-triangle fa-2x text-danger mb-2 d-block"></i>'
+ '<span class="text-danger fw-bold">Erkennungs-Foto ist Pflicht</span>';
searchHolder.style.display = 'block';
}
});
/* ── Mistral test ──────────────────────────────────────────── */
document.getElementById('btn-test-mistral')?.addEventListener('click', async function () {
const btn = this;
const resultDiv = document.getElementById('test-result');
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i>Testing…';
btn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i>Teste…';
resultDiv.style.display = 'none';
const body = new FormData();
body.append('target', 'mistral');
try {
const resp = await fetch('{{ path('admin_ai_test') }}', { method: 'POST', body });
const resp = await fetch('{{ path('admin_ai_test') }}', { method: 'POST', body: new FormData() });
const data = await resp.json();
resultDiv.style.display = 'block';
resultDiv.innerHTML = renderTestResult(data);
} catch (e) {
} catch (err) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="alert alert-danger py-2 mb-0">Request failed: ' + e.message + '</div>';
resultDiv.innerHTML = '<div class="alert alert-danger py-2 mb-0">Fehler: ' + err.message + '</div>';
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-plug me-1"></i>Test Mistral Connection';
btn.innerHTML = '<i class="fa fa-plug me-1"></i>Mistral-Verbindung testen';
}
});
function renderTestResult(data) {
const rows = [
modelRow('Text', data.text),
modelRow('Vision', data.vision),
].join('');
return `<table class="table table-sm mb-0">${rows}</table>`;
return '<table class="table table-sm mb-0">'
+ modelRow('Text', data.text)
+ modelRow('Vision', data.vision)
+ '</table>';
}
function modelRow(label, r) {
if (r.ok) {
if (r?.ok) {
return `<tr>
<td><span class="badge bg-success">OK</span> <strong>${label}</strong></td>
<td class="font-monospace small">${escHtml(r.model)}</td>
<td class="font-monospace small">${e(r.model)}</td>
<td class="text-muted small">${r.ms} ms</td>
</tr>
<tr><td colspan="3" class="ps-3 text-muted small">${escHtml(r.response ?? '')}</td></tr>`;
</tr><tr><td colspan="3" class="ps-3 text-muted small">${e(r.response ?? '')}</td></tr>`;
}
return `<tr>
<td><span class="badge bg-danger">FAIL</span> <strong>${label}</strong></td>
<td colspan="2" class="font-monospace small text-danger">${escHtml(r.error ?? 'unknown error')}</td>
<td colspan="2" class="font-monospace small text-danger">${e(r?.error ?? 'Unbekannter Fehler')}</td>
</tr>`;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function e(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
})();
</script>
{% endblock %}