Conservazione in Sidebar
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* API client per le impostazioni del tenant.
|
||||
*
|
||||
* Endpoint:
|
||||
* GET /api/v1/settings -> legge configurazione (admin)
|
||||
* PUT /api/v1/settings -> aggiorna configurazione (admin)
|
||||
* GET /api/v1/settings/indexing/stats -> statistiche indicizzazione
|
||||
* 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 -> legge configurazione (admin)
|
||||
* PUT /api/v1/settings -> aggiorna configurazione (admin)
|
||||
* GET /api/v1/settings/indexing/stats -> statistiche indicizzazione
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user