- Introduced a new `processor` service in the Docker Compose setup to handle face matching jobs. - Configured Redis as a job queue and state management system for processing searches. - Updated the backend to enqueue jobs and manage user locks using Redis. - Added environment variables for Redis configuration and runtime paths. - Created technical design documentation for the processor service outlining architecture, queue model, and search lifecycle. - Updated package.json and package-lock.json to include dependencies for BullMQ and ioredis in the processor workspace. - Added sample PKL files for local testing in the `test_pkl` directory.
243 lines
8.2 KiB
Vue
243 lines
8.2 KiB
Vue
<script setup>
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import LegacyHeader from '../components/LegacyHeader.vue';
|
|
import { legacyAsset } from '../legacyAssets.js';
|
|
|
|
const coverImageUrl = legacyAsset('/images/layout/Logo_RUS_ETS_tricolore_3-1.jpg');
|
|
|
|
const session = ref(null);
|
|
const loading = ref(true);
|
|
const errorMessage = ref('');
|
|
const selectedFile = ref(null);
|
|
const activeSearch = ref(null);
|
|
const redirectUrl = ref('');
|
|
const isSubmitting = ref(false);
|
|
const isRedirecting = ref(false);
|
|
let pollTimer = null;
|
|
|
|
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
|
|
|
|
const busyLabel = computed(() => {
|
|
if (loading.value) {
|
|
return 'Caricamento sessione FaceAI...';
|
|
}
|
|
|
|
if (isSubmitting.value) {
|
|
return 'Invio del selfie e preparazione della ricerca...';
|
|
}
|
|
|
|
if (isRedirecting.value) {
|
|
return 'Reindirizzamento alla pagina legacy filtrata in corso...';
|
|
}
|
|
|
|
if (activeSearch.value?.status === 'processing') {
|
|
return 'Ricerca biometrica in corso su tutte le foto della gara...';
|
|
}
|
|
|
|
return '';
|
|
});
|
|
|
|
const statusLabel = computed(() => {
|
|
if (!activeSearch.value) {
|
|
return 'Carica un selfie per avviare una ricerca limitata alla gara corrente.';
|
|
}
|
|
|
|
if (activeSearch.value.status === 'completed') {
|
|
return `Ricerca completata. Trovate ${activeSearch.value.matchCount} foto corrispondenti.`;
|
|
}
|
|
|
|
if (activeSearch.value.status === 'failed') {
|
|
return 'La ricerca non e stata completata. Verifica il messaggio di errore e riprova.';
|
|
}
|
|
|
|
return 'Ricerca in corso. Il sistema aggiorna automaticamente lo stato finche il risultato non e pronto.';
|
|
});
|
|
|
|
async function loadSession() {
|
|
loading.value = true;
|
|
const response = await fetch('/api/session', { credentials: 'include' });
|
|
|
|
if (!response.ok) {
|
|
loading.value = false;
|
|
return;
|
|
}
|
|
|
|
session.value = await response.json();
|
|
loading.value = false;
|
|
}
|
|
|
|
function onFileChange(event) {
|
|
selectedFile.value = event.target.files?.[0] || null;
|
|
}
|
|
|
|
async function pollSearch(searchId) {
|
|
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
|
|
if (!response.ok) {
|
|
errorMessage.value = 'Unable to read search status.';
|
|
isSubmitting.value = false;
|
|
return;
|
|
}
|
|
|
|
activeSearch.value = await response.json();
|
|
if (activeSearch.value.status === 'failed') {
|
|
isSubmitting.value = false;
|
|
errorMessage.value = activeSearch.value.errorMessage || 'The search failed.';
|
|
return;
|
|
}
|
|
|
|
if (activeSearch.value.status === 'completed') {
|
|
isSubmitting.value = false;
|
|
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
|
|
const payload = await redirectResponse.json();
|
|
if (!redirectResponse.ok) {
|
|
errorMessage.value = payload.error || 'Unable to build return URL.';
|
|
return;
|
|
}
|
|
redirectUrl.value = payload.url;
|
|
isRedirecting.value = true;
|
|
window.setTimeout(() => {
|
|
window.location.href = payload.url;
|
|
}, 1200);
|
|
return;
|
|
}
|
|
|
|
pollTimer = window.setTimeout(() => {
|
|
pollSearch(searchId);
|
|
}, 1500);
|
|
}
|
|
|
|
async function submitSearch() {
|
|
errorMessage.value = '';
|
|
redirectUrl.value = '';
|
|
isRedirecting.value = false;
|
|
|
|
if (!selectedFile.value) {
|
|
errorMessage.value = 'Choose a selfie before starting the search.';
|
|
return;
|
|
}
|
|
|
|
isSubmitting.value = true;
|
|
|
|
const formData = new FormData();
|
|
formData.set('raceId', session.value.race.id);
|
|
formData.set('selfie', selectedFile.value);
|
|
|
|
const response = await fetch('/api/searches', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData
|
|
});
|
|
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
errorMessage.value = payload.error || 'Unable to create the search.';
|
|
isSubmitting.value = false;
|
|
return;
|
|
}
|
|
|
|
activeSearch.value = payload;
|
|
pollSearch(payload.id);
|
|
}
|
|
|
|
onMounted(loadSession);
|
|
onBeforeUnmount(() => {
|
|
if (pollTimer) {
|
|
window.clearTimeout(pollTimer);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<main>
|
|
<LegacyHeader />
|
|
|
|
<div class="container my-3 faceai-page">
|
|
<div class="row mb-5">
|
|
<div class="col-lg-12">
|
|
<h1 class="my-4">Face ID</h1>
|
|
</div>
|
|
|
|
<div class="col-md-2">
|
|
<img :src="coverImageUrl" class="img-fluid border border-warning" alt="FaceAI" />
|
|
</div>
|
|
|
|
<div class="col-md-10">
|
|
<div class="row riepilogo">
|
|
<div class="col-md-3">
|
|
<p><i class="fa fa-user fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.user.displayName : 'Sessione FaceAI' }}</p>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<p><i class="fa fa-camera-retro fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.race.name : 'Upload selfie' }}</p>
|
|
</div>
|
|
<div class="col">
|
|
<p><i class="fa fa-refresh fa-lg text-warning" aria-hidden="true"></i> {{ activeSearch ? activeSearch.status : 'ready' }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="bg-light faceai-form-shell">
|
|
<div class="row">
|
|
<div class="form-group mx-3 pt-4 pb-1 mb-0 px-2 arrow_box">
|
|
<h2>Cerca le tue foto</h2>
|
|
</div>
|
|
|
|
<div v-if="loading" class="col-12 p-4">
|
|
<div class="faceai-spinner-block">
|
|
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
|
|
<span>{{ busyLabel }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="!session" class="col-12 p-4">
|
|
<p>Apri prima il simulatore legacy per generare il token firmato di handoff.</p>
|
|
<a class="btn btn-warning" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">Apri il simulatore legacy</a>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div class="form-group col-12 col-md-4 mt-3 ml-3">
|
|
<div class="input-group">
|
|
<label for="raceName" class="sr-only">Gara</label>
|
|
<input id="raceName" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.race.name" readonly />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group col-12 col-md-3 mt-3 ml-3">
|
|
<div class="input-group">
|
|
<label for="langView" class="sr-only">Lingua</label>
|
|
<input id="langView" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.lang" readonly />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group col-12 col-md-4 mt-3 ml-3">
|
|
<div class="input-group">
|
|
<label for="selfieUpload" class="sr-only">Selfie</label>
|
|
<input id="selfieUpload" class="form-control form-control-sm mb-2 mb-sm-0" type="file" accept="image/*" @change="onFileChange" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group col-12 mt-2 ml-3 mr-3 faceai-action-row">
|
|
<button class="btn btn-warning" type="button" :disabled="isWorking" @click="submitSearch">Avvia ricerca Face ID</button>
|
|
<a class="btn btn-light" :href="session.returnUrl">Torna alla pagina gara</a>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="faceai-feedback mt-4">
|
|
<p class="lead mb-2">{{ statusLabel }}</p>
|
|
<div v-if="isWorking && busyLabel" class="faceai-spinner-block mb-3">
|
|
<span class="spinner-border spinner-border-sm text-warning" role="status" aria-hidden="true"></span>
|
|
<span>{{ busyLabel }}</span>
|
|
</div>
|
|
<p v-if="activeSearch" class="mb-2">Match trovati: {{ activeSearch.matchCount }}</p>
|
|
<p v-if="redirectUrl" class="mb-2">Reindirizzamento alla pagina legacy filtrata in corso...</p>
|
|
<p v-if="errorMessage" class="text-danger mb-2">{{ errorMessage }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</template>
|