Conservazione in Sidebar
This commit is contained in:
@@ -24,6 +24,10 @@ from fastapi import APIRouter, HTTPException, Query, status
|
|||||||
from app.config import get_settings as get_app_settings
|
from app.config import get_settings as get_app_settings
|
||||||
from app.dependencies import AdminUser, DB
|
from app.dependencies import AdminUser, DB
|
||||||
from app.schemas.tenant_settings import (
|
from app.schemas.tenant_settings import (
|
||||||
|
ConservationStats,
|
||||||
|
ConservationStatsResponse,
|
||||||
|
ConservationTenantBreakdown,
|
||||||
|
ConservationTriggerResult,
|
||||||
ConservatoreTestResult,
|
ConservatoreTestResult,
|
||||||
IndexingJobStatus,
|
IndexingJobStatus,
|
||||||
IndexingStats,
|
IndexingStats,
|
||||||
@@ -418,6 +422,177 @@ async def get_rescan_status(
|
|||||||
return IndexingJobStatus(**status_data)
|
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(
|
@router.post(
|
||||||
"/indexing/rescan",
|
"/indexing/rescan",
|
||||||
response_model=IndexingJobStatus,
|
response_model=IndexingJobStatus,
|
||||||
|
|||||||
@@ -131,3 +131,46 @@ class StartRescanRequest(BaseModel):
|
|||||||
"True: ri-estrae tutti gli allegati, sovrascrivendo i testi gia' presenti."
|
"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
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FascicoliPage } from '@/pages/Fascicoli/FascicoliPage'
|
|||||||
import { FascicoloDetailPage } from '@/pages/Fascicoli/FascicoloDetailPage'
|
import { FascicoloDetailPage } from '@/pages/Fascicoli/FascicoloDetailPage'
|
||||||
import { TaxonomyPage } from '@/pages/Taxonomy/TaxonomyPage'
|
import { TaxonomyPage } from '@/pages/Taxonomy/TaxonomyPage'
|
||||||
import { PermissionPresetsPage } from '@/pages/PermissionPresets/PermissionPresetsPage'
|
import { PermissionPresetsPage } from '@/pages/PermissionPresets/PermissionPresetsPage'
|
||||||
|
import { ConservationPage } from '@/pages/Conservation/ConservationPage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routing principale dell'applicazione PEChub.
|
* Routing principale dell'applicazione PEChub.
|
||||||
@@ -113,6 +114,9 @@ export default function App() {
|
|||||||
{/* Tassonomia di classificazione multi-livello (N2) */}
|
{/* Tassonomia di classificazione multi-livello (N2) */}
|
||||||
<Route path="/taxonomy" element={<TaxonomyPage />} />
|
<Route path="/taxonomy" element={<TaxonomyPage />} />
|
||||||
|
|
||||||
|
{/* Conservazione sostitutiva (admin) */}
|
||||||
|
<Route path="/conservation" element={<ConservationPage />} />
|
||||||
|
|
||||||
{/* Profilo utente */}
|
{/* Profilo utente */}
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
* API client per le impostazioni del tenant.
|
* API client per le impostazioni del tenant.
|
||||||
*
|
*
|
||||||
* Endpoint:
|
* Endpoint:
|
||||||
* GET /api/v1/settings -> legge configurazione (admin)
|
* GET /api/v1/settings -> legge configurazione (admin)
|
||||||
* PUT /api/v1/settings -> aggiorna configurazione (admin)
|
* PUT /api/v1/settings -> aggiorna configurazione (admin)
|
||||||
* GET /api/v1/settings/indexing/stats -> statistiche indicizzazione
|
* GET /api/v1/settings/indexing/stats -> statistiche indicizzazione
|
||||||
* GET /api/v1/settings/indexing/status -> stato job reindex
|
* GET /api/v1/settings/indexing/status -> stato job reindex
|
||||||
* POST /api/v1/settings/indexing/reindex -> avvia reindex
|
* POST /api/v1/settings/indexing/reindex -> avvia reindex
|
||||||
* DELETE /api/v1/settings/indexing/reindex -> cancella 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'
|
import { apiClient } from './client'
|
||||||
@@ -83,6 +85,35 @@ export interface IndexingJobStatus {
|
|||||||
error: string | null
|
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 ──────────────────────────────────────────
|
// ─── Client impostazioni generali ──────────────────────────────────────────
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
@@ -211,4 +242,32 @@ export const settingsApi = {
|
|||||||
)
|
)
|
||||||
return data
|
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: '/signatures', label: 'Firme automatiche', icon: PenLine },
|
||||||
{ to: '/notifications', label: 'Notifiche', icon: Bell },
|
{ to: '/notifications', label: 'Notifiche', icon: Bell },
|
||||||
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
|
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
|
||||||
|
{ to: '/conservation', label: 'Conservazione', icon: ShieldCheck },
|
||||||
] as const).map((item) => (
|
] as const).map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
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