feat(CC-080): add Ingestion Jobs page (data sources + sync events + manual trigger)

This commit is contained in:
admin-valentin 2026-08-02 13:02:09 +00:00
parent 8cd149ed75
commit abd7a485b9

View file

@ -0,0 +1,152 @@
'use client';
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
interface DataSource {
id: string; name: string; type: string; status: string; lastSyncAt: string | null;
errorCount: number | null; recordCount: number | null; config: Record<string, unknown> | null;
createdAt: string;
}
interface AuditEntry {
id: string; action: string; entityType: string | null; entityId: string | null;
details: Record<string, unknown> | null; createdAt: string;
}
const STATUS_META: Record<string, { cls: string; label: string }> = {
active: { cls: 'bg-signal-ok/10 text-signal-ok', label: 'Activ' },
error: { cls: 'bg-signal-danger/10 text-signal-danger', label: 'Eroare' },
paused: { cls: 'bg-muted text-ink-faint', label: 'Pauzat' },
pending: { cls: 'bg-warn/10 text-warn', label: 'Pending' },
};
function relTime(s: string) {
const diff = (Date.now() - new Date(s).getTime()) / 1000;
if (diff < 60) return 'acum';
if (diff < 3600) return `${Math.floor(diff / 60)}min`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}z`;
}
export default function IngestionJobsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const { data: sources = [], isLoading } = useQuery({
queryKey: ['ingestion-sources', tenantId],
queryFn: () => apiFetch<DataSource[]>('/v1/data-sources', { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000, refetchInterval: 60_000,
});
const { data: auditEntries = [] } = useQuery({
queryKey: ['ingestion-audit', tenantId],
queryFn: () => apiFetch<AuditEntry[]>('/v1/audit-log?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const syncEvents = useMemo(() =>
auditEntries.filter((e) => {
const action = e.action.toLowerCase();
return action.includes('sync') || action.includes('import') || action.includes('ingest') || action.includes('crawl');
}),
[auditEntries]);
const triggerMut = useMutation({
mutationFn: (id: string) => apiFetch(`/v1/data-sources/${id}/sync`, { tenantId, method: 'POST', body: {} }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['ingestion-sources', tenantId] }),
});
const healthy = sources.filter((s) => s.status === 'active' && !(s.errorCount ?? 0)).length;
const errors = sources.filter((s) => s.status === 'error' || (s.errorCount ?? 0) > 0).length;
return (
<div className="max-w-4xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Ingestion Jobs</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${sources.length} surse · ${healthy} sănătoase${errors > 0 ? ` · ${errors} cu erori` : ''}`}
</p>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="card p-4 text-center">
<p className="text-2xl font-bold text-ink">{sources.length}</p>
<p className="text-[10px] text-ink-faint">surse totale</p>
</div>
<div className="card p-4 text-center">
<p className="text-2xl font-bold text-signal-ok">{healthy}</p>
<p className="text-[10px] text-ink-faint">sănătoase</p>
</div>
<div className="card p-4 text-center">
<p className={`text-2xl font-bold ${errors > 0 ? 'text-signal-danger' : 'text-ink'}`}>{errors}</p>
<p className="text-[10px] text-ink-faint">cu erori</p>
</div>
<div className="card p-4 text-center">
<p className="text-2xl font-bold text-ink">{syncEvents.length}</p>
<p className="text-[10px] text-ink-faint">sync events</p>
</div>
</div>
{/* Sources table */}
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : sources.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">📡</p>
<p className="text-sm text-ink-faint">Nicio sursă de date configurată. Adaugă din Integrări.</p>
</div>
) : (
<div className="card divide-y divide-border/50">
{sources.map((source) => {
const sm = STATUS_META[source.status] ?? { cls: 'bg-muted text-ink-faint', label: source.status };
return (
<div key={source.id} className="flex items-center gap-4 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-ink">{source.name}</p>
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold ${sm.cls}`}>{sm.label}</span>
{(source.errorCount ?? 0) > 0 && (
<span className="text-[9px] text-signal-danger">{source.errorCount} erori</span>
)}
</div>
<div className="flex flex-wrap gap-3 mt-0.5 text-[10px] text-ink-faint">
<span>Tip: {source.type}</span>
{source.recordCount !== null && <span>{source.recordCount.toLocaleString()} înregistrări</span>}
{source.lastSyncAt && <span>Sync: {relTime(source.lastSyncAt)} în urmă</span>}
</div>
</div>
<button
onClick={() => triggerMut.mutate(source.id)}
disabled={triggerMut.isPending}
className="shrink-0 rounded-lg border px-3 py-1.5 text-xs text-ink hover:bg-muted/50 disabled:opacity-50">
Sync
</button>
</div>
);
})}
</div>
)}
{/* Recent sync events */}
{syncEvents.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Evenimente sync recente</p>
<div className="card divide-y divide-border/50">
{syncEvents.slice(0, 20).map((e) => (
<div key={e.id} className="flex items-center justify-between p-3">
<div>
<p className="text-xs font-medium text-ink">{e.action}</p>
{e.entityType && <p className="text-[10px] text-ink-faint">{e.entityType}</p>}
</div>
<span className="text-[10px] text-ink-faint">{relTime(e.createdAt)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}