Conservazione in Sidebar

This commit is contained in:
2026-06-18 11:53:18 +02:00
parent c68daf4313
commit 042a522854
6 changed files with 753 additions and 6 deletions
+175
View File
@@ -24,6 +24,10 @@ from fastapi import APIRouter, HTTPException, Query, status
from app.config import get_settings as get_app_settings
from app.dependencies import AdminUser, DB
from app.schemas.tenant_settings import (
ConservationStats,
ConservationStatsResponse,
ConservationTenantBreakdown,
ConservationTriggerResult,
ConservatoreTestResult,
IndexingJobStatus,
IndexingStats,
@@ -418,6 +422,177 @@ async def get_rescan_status(
return IndexingJobStatus(**status_data)
# ─── Conservazione sostitutiva statistiche e trigger ───────────────────────
def _build_conservation_stats(
total: int,
conserved: int,
pending: int,
last_conserved_at,
) -> ConservationStats:
not_queued = max(0, total - conserved - pending)
coverage_pct = round((conserved / total * 100), 1) if total > 0 else 0.0
last_dt = last_conserved_at.isoformat() if last_conserved_at else None
return ConservationStats(
total_messages=total,
conserved=conserved,
pending=pending,
not_queued=not_queued,
coverage_pct=coverage_pct,
last_conserved_at=last_dt,
)
@router.get(
"/conservation/stats",
response_model=ConservationStatsResponse,
summary="Statistiche conservazione sostitutiva",
description=(
"Restituisce il numero di messaggi totali, conservati, in coda e non accodati. "
"admin: statistiche del proprio tenant. "
"super_admin con ?tenant_id=<uuid>: statistiche del tenant specificato. "
"super_admin senza tenant_id: statistiche aggregate su tutti i tenant + breakdown per-tenant."
),
)
async def get_conservation_stats(
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare; assente = tutti i tenant",
),
) -> ConservationStatsResponse:
from sqlalchemy import func, select
from app.models.message import Message
from app.models.tenant import Tenant
# Se non e' super_admin, oppure e' super_admin con tenant_id specificato -> singolo tenant
if current_user.role != "super_admin" or tenant_id is not None:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
row = await db.execute(
select(
func.count().label("total"),
func.count().filter(Message.is_conserved == True).label("conserved"), # noqa: E712
func.count().filter(
Message.is_pending_conservation == True, # noqa: E712
Message.is_conserved == False, # noqa: E712
).label("pending"),
func.max(Message.conserved_at).label("last_conserved_at"),
).where(Message.tenant_id == target_tenant_id)
)
r = row.one()
stats = _build_conservation_stats(
total=r.total or 0,
conserved=r.conserved or 0,
pending=r.pending or 0,
last_conserved_at=r.last_conserved_at,
)
return ConservationStatsResponse(stats=stats, per_tenant=None)
# super_admin senza tenant_id: aggregato + per-tenant
rows = await db.execute(
select(
Message.tenant_id,
func.count().label("total"),
func.count().filter(Message.is_conserved == True).label("conserved"), # noqa: E712
func.count().filter(
Message.is_pending_conservation == True, # noqa: E712
Message.is_conserved == False, # noqa: E712
).label("pending"),
func.max(Message.conserved_at).label("last_conserved_at"),
).group_by(Message.tenant_id)
)
tenant_rows = rows.all()
# Carica nomi tenant
all_tenant_ids = [r.tenant_id for r in tenant_rows]
tenant_names: dict[uuid.UUID, tuple[str, str]] = {}
if all_tenant_ids:
t_rows = await db.execute(
select(Tenant.id, Tenant.name, Tenant.slug).where(Tenant.id.in_(all_tenant_ids))
)
for t in t_rows.all():
tenant_names[t.id] = (t.name, t.slug)
per_tenant: list[ConservationTenantBreakdown] = []
agg_total = agg_conserved = agg_pending = 0
agg_last: object = None
for r in tenant_rows:
agg_total += r.total or 0
agg_conserved += r.conserved or 0
agg_pending += r.pending or 0
if r.last_conserved_at and (agg_last is None or r.last_conserved_at > agg_last):
agg_last = r.last_conserved_at
name, slug = tenant_names.get(r.tenant_id, ("(sconosciuto)", ""))
per_tenant.append(ConservationTenantBreakdown(
tenant_id=str(r.tenant_id),
tenant_name=name,
tenant_slug=slug,
stats=_build_conservation_stats(
total=r.total or 0,
conserved=r.conserved or 0,
pending=r.pending or 0,
last_conserved_at=r.last_conserved_at,
),
))
aggregate = _build_conservation_stats(
total=agg_total,
conserved=agg_conserved,
pending=agg_pending,
last_conserved_at=agg_last,
)
return ConservationStatsResponse(stats=aggregate, per_tenant=per_tenant)
@router.post(
"/conservation/trigger",
response_model=ConservationTriggerResult,
status_code=status.HTTP_202_ACCEPTED,
summary="Avvia manualmente il job di conservazione sostitutiva",
description=(
"Accoda immediatamente il job run_conservation nel worker arq. "
"Il job elabora tutti i tenant con messaggi pendenti di conservazione. "
"Utile per eseguire il ciclo di conservazione senza attendere il cron giornaliero. "
"Solo admin e super_admin possono avviarlo."
),
)
async def trigger_conservation(
current_user: AdminUser,
db: DB,
) -> ConservationTriggerResult:
app_settings = get_app_settings()
try:
import urllib.parse
from arq.connections import RedisSettings, create_pool
parsed = urllib.parse.urlparse(app_settings.redis_url)
redis_settings = RedisSettings(
host=parsed.hostname or "localhost",
port=parsed.port or 6379,
database=int(parsed.path.lstrip("/") or "0"),
password=parsed.password or None,
)
redis = await create_pool(redis_settings)
job = await redis.enqueue_job("run_conservation")
await redis.aclose()
job_id = job.job_id if job else None
return ConservationTriggerResult(
queued=True,
message="Job di conservazione accodato con successo. Verra' eseguito dal worker entro pochi secondi.",
job_id=job_id,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Impossibile accodare il job: {exc}",
)
@router.post(
"/indexing/rescan",
response_model=IndexingJobStatus,
+43
View File
@@ -131,3 +131,46 @@ class StartRescanRequest(BaseModel):
"True: ri-estrae tutti gli allegati, sovrascrivendo i testi gia' presenti."
),
)
# ─── Schemi conservazione sostitutiva ────────────────────────────────────────
class ConservationStats(BaseModel):
"""Statistiche di conservazione sostitutiva per un tenant."""
total_messages: int
conserved: int # is_conserved=True
pending: int # is_pending_conservation=True AND is_conserved=False
not_queued: int # non ancora marcati per conservazione
coverage_pct: float # conserved / total * 100
last_conserved_at: Optional[str] = None # ISO datetime del piu' recente messaggio conservato
class ConservationTenantBreakdown(BaseModel):
"""Statistiche di un singolo tenant (usato da super_admin)."""
tenant_id: str
tenant_name: str
tenant_slug: str
stats: ConservationStats
class ConservationStatsResponse(BaseModel):
"""
Risposta GET /settings/conservation/stats.
- admin: stats.stats contiene le stat del suo tenant; per_tenant e' None
- super_admin con tenant_id: stats del tenant specificato; per_tenant e' None
- super_admin senza tenant_id: stats aggregate su tutti i tenant; per_tenant e' la lista per-tenant
"""
stats: ConservationStats
per_tenant: Optional[list[ConservationTenantBreakdown]] = None
class ConservationTriggerResult(BaseModel):
"""Risposta POST /settings/conservation/trigger."""
queued: bool
message: str
job_id: Optional[str] = None
+4
View File
@@ -23,6 +23,7 @@ import { FascicoliPage } from '@/pages/Fascicoli/FascicoliPage'
import { FascicoloDetailPage } from '@/pages/Fascicoli/FascicoloDetailPage'
import { TaxonomyPage } from '@/pages/Taxonomy/TaxonomyPage'
import { PermissionPresetsPage } from '@/pages/PermissionPresets/PermissionPresetsPage'
import { ConservationPage } from '@/pages/Conservation/ConservationPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -113,6 +114,9 @@ export default function App() {
{/* Tassonomia di classificazione multi-livello (N2) */}
<Route path="/taxonomy" element={<TaxonomyPage />} />
{/* Conservazione sostitutiva (admin) */}
<Route path="/conservation" element={<ConservationPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+59
View File
@@ -8,6 +8,8 @@
* GET /api/v1/settings/indexing/status -> stato job reindex
* POST /api/v1/settings/indexing/reindex -> avvia reindex
* DELETE /api/v1/settings/indexing/reindex -> cancella reindex
* GET /api/v1/settings/conservation/stats -> statistiche conservazione
* POST /api/v1/settings/conservation/trigger -> avvia job conservazione manuale
*/
import { apiClient } from './client'
@@ -83,6 +85,35 @@ export interface IndexingJobStatus {
error: string | null
}
// ─── Tipi conservazione sostitutiva ───────────────────────────────────────
export interface ConservationStats {
total_messages: number
conserved: number
pending: number
not_queued: number
coverage_pct: number // 0-100
last_conserved_at: string | null // ISO datetime
}
export interface ConservationTenantBreakdown {
tenant_id: string
tenant_name: string
tenant_slug: string
stats: ConservationStats
}
export interface ConservationStatsResponse {
stats: ConservationStats
per_tenant: ConservationTenantBreakdown[] | null
}
export interface ConservationTriggerResult {
queued: boolean
message: string
job_id: string | null
}
// ─── Client impostazioni generali ──────────────────────────────────────────
export const settingsApi = {
@@ -211,4 +242,32 @@ export const settingsApi = {
)
return data
},
// ── Conservazione sostitutiva ─────────────────────────────────────────────
/**
* Restituisce le statistiche di conservazione sostitutiva.
* admin: stats del proprio tenant.
* super_admin senza tenantId: stats aggregate su tutti i tenant + per_tenant.
* super_admin con tenantId: stats del tenant specificato.
*/
getConservationStats: async (tenantId?: string): Promise<ConservationStatsResponse> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.get<ConservationStatsResponse>(
'/settings/conservation/stats',
{ params }
)
return data
},
/**
* Avvia manualmente il job di conservazione sostitutiva.
* Il job verra' eseguito dal worker entro pochi secondi.
*/
triggerConservation: async (): Promise<ConservationTriggerResult> => {
const { data } = await apiClient.post<ConservationTriggerResult>(
'/settings/conservation/trigger'
)
return data
},
}
@@ -587,6 +587,7 @@ export function Sidebar() {
{ to: '/signatures', label: 'Firme automatiche', icon: PenLine },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
{ to: '/conservation', label: 'Conservazione', icon: ShieldCheck },
] as const).map((item) => (
<NavLink
key={item.to}
@@ -0,0 +1,465 @@
/**
* ConservationPage statistiche e gestione manuale della conservazione sostitutiva.
*
* Visibile solo ad admin e super_admin.
*
* Funzionalita':
* - Statistiche messaggi: totali, conservati, in coda, non accodati
* - Barra di copertura (% conservati su totali)
* - Data ultimo messaggio conservato
* - Super_admin: dropdown per selezionare un tenant specifico o "tutti i tenant"
* con tabella breakdown per-tenant
* - Pulsante "Avvia conservazione manuale" con dialogo di conferma
*/
import { useEffect, useState } from 'react'
import {
ShieldCheck,
RefreshCw,
Play,
AlertTriangle,
CheckCircle,
Clock,
Loader2,
BarChart3,
User,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
import { tenantsApi } from '@/api/tenants.api'
import {
settingsApi,
type ConservationStatsResponse,
type ConservationStats,
} from '@/api/settings.api'
import type { TenantResponse } from '@/types/api.types'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import toast from 'react-hot-toast'
// ─── Utility ─────────────────────────────────────────────────────────────────
function formatDatetime(iso: string | null): string {
if (!iso) return '-'
return new Date(iso).toLocaleString('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// ─── Barra di avanzamento ─────────────────────────────────────────────────────
function ProgressBar({
value,
max = 100,
color = 'teal',
}: {
value: number
max?: number
color?: 'teal' | 'green' | 'amber' | 'red' | 'gray'
}) {
const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
const colorClass = {
teal: 'bg-teal-500',
green: 'bg-green-500',
amber: 'bg-amber-500',
red: 'bg-red-500',
gray: 'bg-gray-400',
}[color]
return (
<div className="w-full bg-gray-100 rounded-full h-2.5 overflow-hidden">
<div
className={`h-2.5 rounded-full transition-all duration-500 ${colorClass}`}
style={{ width: `${pct}%` }}
/>
</div>
)
}
// ─── Card stat singola ────────────────────────────────────────────────────────
function StatCard({
label,
value,
colorClass = 'text-gray-800',
bgClass = 'bg-gray-50',
borderClass = 'border-gray-200',
}: {
label: string
value: number
colorClass?: string
bgClass?: string
borderClass?: string
}) {
return (
<div className={`rounded-lg border p-3 text-center ${bgClass} ${borderClass}`}>
<p className={`text-2xl font-bold ${colorClass}`}>
{value.toLocaleString('it-IT')}
</p>
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
</div>
)
}
// ─── Blocco statistiche (riusato per singolo tenant e aggregato) ──────────────
function StatsBlock({ stats }: { stats: ConservationStats }) {
const coverageColor = stats.coverage_pct >= 90 ? 'teal' : stats.coverage_pct >= 60 ? 'amber' : 'red'
const coverageTextColor =
stats.coverage_pct >= 90 ? 'text-teal-700' :
stats.coverage_pct >= 60 ? 'text-amber-700' : 'text-red-700'
return (
<div className="space-y-4">
{/* Griglia statistiche */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Totali" value={stats.total_messages} />
<StatCard
label="Conservati"
value={stats.conserved}
colorClass="text-teal-700"
bgClass="bg-teal-50"
borderClass="border-teal-200"
/>
<StatCard
label="In coda"
value={stats.pending}
colorClass={stats.pending > 0 ? 'text-amber-700' : 'text-gray-500'}
bgClass={stats.pending > 0 ? 'bg-amber-50' : 'bg-gray-50'}
borderClass={stats.pending > 0 ? 'border-amber-200' : 'border-gray-200'}
/>
<StatCard
label="Non accodati"
value={stats.not_queued}
colorClass={stats.not_queued > 0 ? 'text-gray-600' : 'text-gray-400'}
/>
</div>
{/* Barra copertura */}
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-gray-600">Copertura conservazione</span>
<span className={`font-semibold ${coverageTextColor}`}>
{stats.coverage_pct}%
</span>
</div>
<ProgressBar
value={stats.conserved}
max={stats.total_messages}
color={coverageColor}
/>
{stats.pending > 0 && (
<p className="text-xs text-amber-600">
{stats.pending.toLocaleString('it-IT')} messaggi in coda verranno elaborati al prossimo run.
</p>
)}
</div>
{/* Ultima conservazione */}
<div className="flex items-center gap-2 text-xs text-gray-500">
<Clock className="h-3.5 w-3.5 flex-shrink-0" />
<span>
Ultima conservazione: <strong className="text-gray-700">{formatDatetime(stats.last_conserved_at)}</strong>
</span>
</div>
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function ConservationPage() {
const { user } = useAuth()
const isSuperAdmin = user?.role === 'super_admin'
const [data, setData] = useState<ConservationStatsResponse | null>(null)
const [loading, setLoading] = useState(false)
const [triggering, setTriggering] = useState(false)
const [showTriggerConfirm, setShowTriggerConfirm] = useState(false)
const [perTenantExpanded, setPerTenantExpanded] = useState(false)
// Selezione tenant per super_admin
const [tenantList, setTenantList] = useState<TenantResponse[]>([])
// selectedTenantId vuoto = "tutti i tenant" (solo super_admin)
const [selectedTenantId, setSelectedTenantId] = useState<string>('')
// Carica lista tenant per super_admin
useEffect(() => {
if (isSuperAdmin) {
tenantsApi.list().then(setTenantList).catch(() => {})
}
}, [isSuperAdmin])
const loadStats = async () => {
setLoading(true)
try {
const tenantId = isSuperAdmin && selectedTenantId ? selectedTenantId : undefined
const result = await settingsApi.getConservationStats(tenantId)
setData(result)
} catch {
toast.error('Errore durante il caricamento delle statistiche di conservazione')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadStats()
}, [selectedTenantId])
const handleTrigger = async () => {
setTriggering(true)
setShowTriggerConfirm(false)
try {
const result = await settingsApi.triggerConservation()
if (result.queued) {
toast.success(result.message)
// Ricarica le statistiche dopo qualche secondo
setTimeout(() => loadStats(), 3000)
} else {
toast.error('Il job non e\' stato accodato')
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })
?.response?.data?.detail
toast.error(msg ?? 'Errore durante l\'avvio della conservazione')
} finally {
setTriggering(false)
}
}
if (!user) return null
// Titolo sezione basato sulla selezione
const sectionTitle = isSuperAdmin && !selectedTenantId
? 'Tutti i tenant (aggregato)'
: isSuperAdmin && selectedTenantId
? (tenantList.find((t) => t.id === selectedTenantId)?.name ?? 'Tenant selezionato')
: 'Il tuo tenant'
return (
<div className="p-6 max-w-3xl mx-auto space-y-6">
{/* ── Intestazione ── */}
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-teal-600 flex items-center justify-center">
<ShieldCheck className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">Conservazione Sostitutiva</h1>
<p className="text-sm text-gray-500">
Statistiche e gestione del ciclo di conservazione documentale AgID
</p>
</div>
</div>
{/* ── Selector tenant (solo super_admin) ── */}
{isSuperAdmin && tenantList.length > 0 && (
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<User className="h-3.5 w-3.5 text-purple-600" />
<span className="text-xs font-semibold text-purple-800 uppercase tracking-wide">
Selezione Tenant (Super Admin)
</span>
</div>
<select
className="w-full rounded-md border border-purple-300 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
>
<option value="">Tutti i tenant (aggregato globale)</option>
{tenantList.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug}){!t.is_active ? ' — SOSPESO' : ''}
</option>
))}
</select>
</Card>
)}
{/* ── Card statistiche ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Statistiche {sectionTitle}
</h2>
</div>
<button
className="text-xs text-teal-600 hover:text-teal-800 flex items-center gap-1 disabled:opacity-50"
onClick={loadStats}
disabled={loading}
>
<RefreshCw className={`h-3 w-3 ${loading ? 'animate-spin' : ''}`} />
Aggiorna
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-10 gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Caricamento statistiche...</span>
</div>
) : data ? (
<StatsBlock stats={data.stats} />
) : (
<p className="text-sm text-gray-400 text-center py-6">Nessun dato disponibile</p>
)}
</Card>
{/* ── Tabella per-tenant (solo super_admin senza selezione specifica) ── */}
{isSuperAdmin && !selectedTenantId && data?.per_tenant && data.per_tenant.length > 0 && (
<Card className="p-5">
<button
className="w-full flex items-center justify-between text-left mb-1"
onClick={() => setPerTenantExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-gray-400" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Dettaglio per tenant ({data.per_tenant.length})
</h2>
</div>
{perTenantExpanded
? <ChevronUp className="h-4 w-4 text-gray-400" />
: <ChevronDown className="h-4 w-4 text-gray-400" />
}
</button>
{perTenantExpanded && (
<div className="mt-4 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 pr-3 font-semibold text-gray-500 uppercase tracking-wide">Tenant</th>
<th className="text-right py-2 px-2 font-semibold text-gray-500 uppercase tracking-wide">Totali</th>
<th className="text-right py-2 px-2 font-semibold text-teal-600 uppercase tracking-wide">Conservati</th>
<th className="text-right py-2 px-2 font-semibold text-amber-600 uppercase tracking-wide">In coda</th>
<th className="text-right py-2 px-2 font-semibold text-gray-500 uppercase tracking-wide">Non accodati</th>
<th className="text-right py-2 pl-2 font-semibold text-gray-500 uppercase tracking-wide">Copertura</th>
</tr>
</thead>
<tbody>
{data.per_tenant.map((t) => (
<tr key={t.tenant_id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-2 pr-3">
<p className="font-medium text-gray-800">{t.tenant_name}</p>
<p className="text-gray-400">{t.tenant_slug}</p>
</td>
<td className="text-right py-2 px-2 text-gray-700">
{t.stats.total_messages.toLocaleString('it-IT')}
</td>
<td className="text-right py-2 px-2 font-medium text-teal-700">
{t.stats.conserved.toLocaleString('it-IT')}
</td>
<td className={`text-right py-2 px-2 font-medium ${t.stats.pending > 0 ? 'text-amber-600' : 'text-gray-400'}`}>
{t.stats.pending.toLocaleString('it-IT')}
</td>
<td className="text-right py-2 px-2 text-gray-500">
{t.stats.not_queued.toLocaleString('it-IT')}
</td>
<td className="text-right py-2 pl-2">
<span className={`font-semibold ${
t.stats.coverage_pct >= 90 ? 'text-teal-700' :
t.stats.coverage_pct >= 60 ? 'text-amber-600' : 'text-red-600'
}`}>
{t.stats.coverage_pct}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* ── Card azione manuale ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center gap-2">
<Play className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Azione Manuale
</h2>
</div>
<div className="rounded-lg bg-gray-50 border border-gray-200 p-3 text-xs text-gray-600 space-y-1">
<p>
Il ciclo di conservazione viene eseguito automaticamente ogni giorno alle <strong>18:00</strong> (ora italiana).
Usa questo pulsante per avviarlo immediatamente senza attendere il cron giornaliero.
</p>
<p>
Il job elabora tutti i tenant con messaggi pendenti (<code>is_pending_conservation=TRUE</code>)
e invia i pacchetti SIP al conservatore configurato. I messaggi gia' conservati
(<code>is_conserved=TRUE</code>) vengono saltati.
</p>
</div>
{/* Dialogo conferma */}
{showTriggerConfirm && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-800">Conferma avvio conservazione</p>
<p className="text-xs text-amber-700 mt-0.5">
Il job verra' accodato immediatamente e il worker lo eseguira' entro pochi secondi.
Vengono processati tutti i tenant con messaggi pendenti.
</p>
<div className="flex gap-3 mt-2">
<button
className="text-xs font-medium text-amber-800 underline hover:no-underline"
onClick={handleTrigger}
disabled={triggering}
>
Confermo, avvia ora
</button>
<span className="text-amber-500">·</span>
<button
className="text-xs font-medium text-gray-600 underline hover:no-underline"
onClick={() => setShowTriggerConfirm(false)}
>
Annulla
</button>
</div>
</div>
</div>
)}
<Button
onClick={() => setShowTriggerConfirm(true)}
disabled={triggering || showTriggerConfirm}
className="flex items-center gap-2 bg-teal-600 hover:bg-teal-700 text-white"
>
{triggering
? <Loader2 className="h-4 w-4 animate-spin" />
: <Play className="h-4 w-4" />
}
{triggering ? 'Avvio in corso...' : 'Avvia conservazione manuale'}
</Button>
</Card>
{/* ── Info ciclo cron ── */}
<div className="rounded-lg bg-teal-50 border border-teal-100 p-4 text-xs text-teal-800 space-y-1">
<p className="font-medium">Come funziona il ciclo di conservazione</p>
<p>
Il worker arq esegue automaticamente <code>run_conservation</code> ogni giorno alle 18:00 (ora italiana).
Per ogni messaggio marcato <em>da conservare</em>, viene costruito un pacchetto SIP BagIt completo
(EML principale + allegati + ricevute PEC) e inviato al conservatore configurato (es. Aeterna).
Dopo il successo, il messaggio viene marcato <code>is_conserved=TRUE</code>.
In caso di errore rimane in coda e verra' ritentato al prossimo run giornaliero.
</p>
<p className="flex items-center gap-1 mt-1">
<CheckCircle className="h-3.5 w-3.5 text-teal-600 flex-shrink-0" />
La conservazione e' idempotente: i messaggi gia' conservati non vengono rielaborati.
</p>
</div>
</div>
)
}