feat(CC-080): add Ingestion Jobs page (data sources + sync events + manual trigger)
This commit is contained in:
parent
8cd149ed75
commit
abd7a485b9
1 changed files with 152 additions and 0 deletions
152
src/app/dashboard/data/ingestion/page.tsx
Normal file
152
src/app/dashboard/data/ingestion/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue