Files
atc-agents/ui/src/components/features/DataQualityView.tsx
T
mo f36c8906bc feat(governance): close the 6 data-disease gaps — DQ monitoring, ownership, access posture, lineage & observability
Adds native Command Center features (no new containers) integrated as sub-tabs
in the existing Data Explorer and Data Quality views:

- Continuous Data Quality (dq_monitor.py): live completeness/uniqueness/validity/
  freshness scorecards via Trino with rolling trends → DataQuality "Live Monitoring".
- Ownership & stewardship (catalog_governance.py): owner/steward/tier matrix,
  orphan detection, business glossary; local store best-effort synced to
  OpenMetadata (owner PATCH) → Data Explorer "Ownership".
- Access & policy posture: per-dataset compliance combining PII masking, ownership,
  live DQ and observability alerts vs data contracts → Data Explorer "Access & Policies".
- Lineage (lineage.py): staged source→CDC→Spark→S3→Iceberg→Trino→serving graph with
  live row counts and column-level PII/masking tracing → Data Explorer "Lineage".
- Observability (observability.py): volume/freshness/schema-drift monitoring with
  alerts → Data Explorer "Observability".
- Shared lake_meta.py dataset registry + bounded Trino client; fast native row-count
  and PK-indexed freshness so monitors stay cheap on 25-54M-row tables.
- LLM context (lab_context.py) enriched with DQ scores, ownership and active alerts.
2026-06-29 17:43:37 +00:00

873 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState, Fragment } from 'react'
import {
AlertTriangle,
CheckCircle2,
FileSearch,
FileText,
Image,
Layers,
Loader2,
RefreshCw,
Table2,
Upload,
XCircle,
Gauge,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
import { DqMonitoringPanel } from './DqMonitoringPanel'
type Dimension = {
id: string
label: string
description: string
score: number
level: string
findings: string[]
recommended_actions?: string[]
}
type ColumnProfile = {
name: string
dtype: string
null_pct: number
unique_count: number
quality_flags: string[]
sample_values?: string[]
numeric?: { min: number; max: number; mean: number; outliers: number }
text?: { avg_length: number; empty_strings: number }
top_values?: { value: string; count: number }[]
}
type Violations = {
violation_count?: number
violation_pct?: number
sample_rows?: { row_index: number; values: Record<string, unknown> }[]
location_hint?: string
affected_columns?: string[]
}
type DqCheck = {
id?: string
suite: string
success?: boolean
status?: string
outcome?: string
expectation?: string
name?: string
metric?: string
check?: string
test?: string
rule?: string
monitor?: string
result?: string
detail?: string
column?: string
violations?: Violations
meta?: Record<string, unknown>
}
type GxCheck = DqCheck & { expectation: string; success: boolean; result: string }
type SodaCheck = DqCheck & { name: string; check: string; outcome: string; detail: string }
type DocStructure = {
pages: number
pictures: number
tables: number
text_blocks: number
headings: number
paragraphs: number
list_items?: number
form_items: number
key_value_pairs: number
label_counts?: Record<string, number>
table_details?: { index: number; rows: number; cols: number; cells: number; preview?: string }[]
picture_details?: { index: number; label: string; has_image: boolean; captions: number }[]
outline?: { type: string; text: string; level?: number }[]
}
type AssessResult = {
ok: boolean
report_id: string
overall_score: number
maturity_level: string
maturity_description?: string
rows: number
columns: number
dimensions: Dimension[]
column_profiles: ColumnProfile[]
action_items: { priority: string; dimension: string; score: number; action: string }[]
checks?: {
great_expectations: GxCheck[]
soda_core: SodaCheck[]
deequ?: DqCheck[]
pandera?: DqCheck[]
dbt_expectations?: DqCheck[]
monte_carlo?: DqCheck[]
affirm?: DqCheck[]
}
checks_summary: Record<string, { total: number; passed?: number; warnings?: number }>
executive_summary?: {
headline: string
dataset: string
checks_run: number
issues_found: number
flagged_columns: number
tools: string[]
recommendation: string
}
docling?: { used: boolean; parse_id?: string; document_structure?: DocStructure; stats?: Record<string, number>; images?: DocImage[] }
rag_ingest?: { ok: boolean; duplicate?: boolean; chunks?: number; message?: string; error?: string }
report_url: string
}
type DocImage = {
index: number
label: string
available: boolean
url?: string
width?: number
height?: number
mimetype?: string
dpi?: number
bytes?: number
captions?: string[]
}
type ParseResult = {
ok: boolean
parse_id: string
filename: string
status: string
processing_time_sec?: number
formats_available: string[]
document_structure: DocStructure
images?: DocImage[]
stats: Record<string, number>
content: { preview_markdown?: string; preview_html?: string; markdown?: string; html?: string }
table_preview?: string[]
errors?: string[]
parse_json_url?: string
}
type Capabilities = {
maturity_dimensions: { id: string; label: string; description: string }[]
maturity_levels: { min_score: number; label: string; description: string }[]
supported_data_formats: string[]
supported_document_formats: string[]
tools: Record<string, { status: string; capabilities?: string[] }>
docling_online: boolean
}
type ReportSummary = {
id: string
filename: string
ts: string
overall_score: number
maturity_level: string
rows: number
columns: number
}
type Tab = 'assess' | 'docling' | 'reports' | 'monitoring'
const SCORE_COLOR = (s: number) => (s >= 80 ? 'text-success' : s >= 60 ? 'text-warning' : 'text-danger')
const BAR_COLOR = (s: number) => (s >= 80 ? 'bg-success' : s >= 60 ? 'bg-warning' : 'bg-danger')
export function DataQualityView() {
const [tab, setTab] = useState<Tab>('monitoring')
const [caps, setCaps] = useState<Capabilities | null>(null)
const [loading, setLoading] = useState(false)
const [assess, setAssess] = useState<AssessResult | null>(null)
const [parse, setParse] = useState<ParseResult | null>(null)
const [parseFormat, setParseFormat] = useState<'markdown' | 'html'>('markdown')
const [reports, setReports] = useState<ReportSummary[]>([])
const [error, setError] = useState<string | null>(null)
const [expandedCol, setExpandedCol] = useState<string | null>(null)
const [expandedCheck, setExpandedCheck] = useState<string | null>(null)
const [expandedDim, setExpandedDim] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState<string>('great_expectations')
const loadMeta = useCallback(async () => {
try {
const [c, r] = await Promise.all([fetch('/dq/capabilities'), fetch('/dq/reports')])
if (c.ok) setCaps(await c.json())
if (r.ok) {
const j = await r.json()
setReports(j.reports || [])
}
} catch {
/* ignore */
}
}, [])
useEffect(() => {
loadMeta()
}, [loadMeta])
const onAssess = async (file: File) => {
setLoading(true)
setError(null)
setAssess(null)
const fd = new FormData()
fd.append('file', file)
try {
const r = await fetch('/dq/assess', { method: 'POST', body: fd })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || j.detail || 'Assessment failed')
return
}
setAssess(j as AssessResult)
loadMeta()
} catch {
setError('Connection failed — check DQ API')
} finally {
setLoading(false)
}
}
const onParse = async (file: File) => {
setLoading(true)
setError(null)
setParse(null)
const fd = new FormData()
fd.append('file', file)
fd.append('to_formats', 'md,html,json')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 300000)
try {
const r = await fetch('/dq/parse', { method: 'POST', body: fd, signal: ctrl.signal })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(typeof j.error === 'string' ? j.error : JSON.stringify(j.error || j).slice(0, 200) || 'Docling parse failed')
return
}
setParse(j as ParseResult)
loadMeta()
} catch (e) {
setError(e instanceof Error && e.name === 'AbortError' ? 'Timeout — document too large or Docling overloaded' : 'Docling unavailable')
} finally {
clearTimeout(timer)
setLoading(false)
}
}
const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [
{ id: 'monitoring', label: 'Live Monitoring', icon: Gauge },
{ id: 'assess', label: 'Maturity Assessment', icon: FileSearch },
{ id: 'docling', label: 'Docling Parser', icon: FileText },
{ id: 'reports', label: 'Reports', icon: CheckCircle2 },
]
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Data Quality & Maturity Platform</h2>
<p className="text-[11px] text-foreground-muted">
Full data maturity assessment for customer data Docling, Great Expectations, Soda Core
</p>
</div>
<div className="flex items-center gap-2">
<span className={cn('rounded-full px-2.5 py-1 text-[10px] font-medium', caps?.docling_online ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
Docling {caps?.docling_online ? '● online' : '○ offline'}
</span>
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
<a href={`http://${window.location.hostname}:5001/ui/`} target="_blank" rel="noreferrer" className="rounded border border-docker/40 bg-docker/15 px-2 py-1 text-[10px] text-docker">
Docling UI
</a>
</div>
</div>
{caps && (
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<CapCard title="Maturity Engine" items={caps.maturity_dimensions.map((d) => d.label)} icon={Layers} />
<CapCard title="Data Quality Tools" items={['Great Expectations', 'Soda Core', 'Deequ', 'Pandera', 'dbt', 'Monte Carlo', 'Affirm', 'Pandas']} icon={CheckCircle2} />
<CapCard title="Document Parsing" items={caps.tools.docling?.capabilities || ['PDF', 'PPTX', 'DOCX']} icon={FileText} />
<CapCard title="File formats" items={[...caps.supported_data_formats.slice(0, 4), ...caps.supported_document_formats.slice(0, 3)]} icon={Upload} />
</div>
)}
</header>
<div className="flex shrink-0 gap-1 border-b border-border bg-surface-overlay/20 px-3 py-2">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn('flex items-center gap-1.5 rounded-md px-3 py-2 text-[11px] font-medium transition-all', tab === id ? subTabActive : subTabIdle)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-[11px] text-danger">
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{tab === 'monitoring' && <DqMonitoringPanel />}
{tab === 'assess' && (
<div className="space-y-5">
<UploadZone
loading={loading}
label="Upload customer data for full maturity assessment"
hint="CSV · Excel · JSON · Parquet · PDF · PPTX · DOCX"
accept=".csv,.tsv,.xlsx,.xls,.json,.parquet,.pdf,.pptx,.ppt,.docx"
onFile={onAssess}
/>
{loading && <LoadingMsg text="Analyzing: 6 maturity dimensions · GE checks · Soda checks · column profiles…" />}
{assess && (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatCard label="Overall Score" value={`${assess.overall_score}`} sub="/100" accent />
<StatCard label="Maturity Level" value={assess.maturity_level} sub={assess.maturity_description} />
<StatCard label="Dataset" value={`${assess.rows.toLocaleString()}`} sub={`${assess.columns} columns`} />
<StatCard label="DQ Checks" value={String(assess.executive_summary?.checks_run || Object.values(assess.checks_summary).reduce((s, x) => s + (x.total || 0), 0))} sub="across 7 tools" />
<StatCard label="Issues" value={String(assess.executive_summary?.issues_found ?? 0)} sub="failed/warn checks" warn={(assess.executive_summary?.issues_found ?? 0) > 0} />
</div>
{assess.docling?.used && assess.docling.document_structure && (
<>
<DocStructurePanel structure={assess.docling.document_structure} title="Document structure (via Docling)" />
{assess.docling.images && assess.docling.images.length > 0 && assess.docling.parse_id && (
<ImageGallery images={assess.docling.images} parseId={assess.docling.parse_id} />
)}
</>
)}
{assess.rag_ingest && (
<div className={cn(
'rounded-lg border px-3 py-2 text-[11px]',
assess.rag_ingest.ok ? 'border-success/30 bg-success/10 text-success' : 'border-warning/30 bg-warning/10 text-warning',
)}>
<p className="font-medium">Knowledge Chat sync</p>
<p className="text-foreground-muted">
{assess.rag_ingest.ok
? (assess.rag_ingest.duplicate
? `Already in Knowledge Chat — ${assess.rag_ingest.message || 'you can chat immediately.'}`
: `Indexed for chat: ${assess.rag_ingest.chunks ?? '?'} text chunks. Open Knowledge Chat to ask questions.`)
: (assess.rag_ingest.error || 'Could not sync to Knowledge Chat')}
</p>
</div>
)}
{assess.executive_summary && (
<section className="rounded-lg border border-docker/30 bg-docker/5 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-docker">Executive Summary Customer Report</h3>
<p className="text-[13px] font-semibold text-foreground">{assess.executive_summary.headline}</p>
<p className="mt-1 text-[11px] text-foreground-muted">{assess.executive_summary.dataset}</p>
<div className="mt-2 flex flex-wrap gap-3 text-[10px]">
<span>{assess.executive_summary.checks_run} checks run</span>
<span className={assess.executive_summary.issues_found > 0 ? 'text-warning' : 'text-success'}>{assess.executive_summary.issues_found} issues found</span>
<span>{assess.executive_summary.flagged_columns} flagged columns</span>
</div>
<p className="mt-2 border-t border-border/50 pt-2 text-[11px] text-foreground-muted">
<strong className="text-foreground">Recommendation:</strong> {assess.executive_summary.recommendation}
</p>
</section>
)}
<div className="flex flex-wrap gap-2">
<a href={assess.report_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
Full HTML report
</a>
<a href={assess.report_url + '/json'} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
JSON export
</a>
</div>
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">Data Quality Tools click any row for error details</h3>
<div className="mb-2 flex flex-wrap gap-1">
{([
['great_expectations', 'Great Expectations'],
['soda_core', 'Soda Core'],
['deequ', 'AWS Deequ'],
['pandera', 'Pandera'],
['dbt_expectations', 'dbt Expectations'],
['monte_carlo', 'Monte Carlo'],
['affirm', 'Affirm'],
] as const).map(([key, label]) => {
const items = assess.checks?.[key] || []
if (!items.length) return null
const summary = assess.checks_summary?.[key]
const badge = summary?.passed !== undefined ? `${summary.passed}/${summary.total}` : `${items.length}`
return (
<button key={key} type="button" onClick={() => setActiveTool(key)} className={cn('rounded-md px-2.5 py-1 text-[10px]', activeTool === key ? subTabActive : subTabIdle)}>
{label} ({badge})
</button>
)
})}
</div>
{assess.checks && (
<CheckTable
title={activeTool.replace(/_/g, ' ')}
checks={normalizeChecks(assess.checks[activeTool as keyof typeof assess.checks] || [])}
expandedId={expandedCheck}
onToggle={setExpandedCheck}
/>
)}
</section>
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">6 Maturity Dimensions</h3>
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
{assess.dimensions.map((d) => (
<DimensionCard key={d.id} dimension={d} expanded={expandedDim === d.id} onToggle={() => setExpandedDim(expandedDim === d.id ? null : d.id)} />
))}
</div>
</section>
{assess.action_items.length > 0 && (
<section>
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<AlertTriangle className="h-3.5 w-3.5 text-warning" /> Remediation Roadmap
</h3>
<div className="space-y-1.5">
{assess.action_items.map((a, i) => (
<div key={i} className={cn('rounded-lg border px-3 py-2 text-[11px]', a.priority === 'high' ? 'border-danger/40 bg-danger/10' : a.priority === 'medium' ? 'border-warning/40 bg-warning/10' : 'border-border bg-surface-overlay/40')}>
<span className="font-bold uppercase text-foreground-faint">{a.priority}</span>
{' · '}<strong>{a.dimension}</strong> ({a.score}): {a.action}
</div>
))}
</div>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Column profiles ({assess.column_profiles.length})
</h3>
<ColumnTable profiles={assess.column_profiles} expandedCol={expandedCol} onToggle={setExpandedCol} />
</section>
</div>
)}
</div>
)}
{tab === 'docling' && (
<div className="space-y-5">
<p className="text-[12px] leading-relaxed text-foreground-muted">
Docling extracts text, tables, images and document structure from PDF, PowerPoint, Word, Excel and images.
Output: Markdown, HTML, JSON with pages, images, tables and document outline.
</p>
<UploadZone
loading={loading}
label="Upload document for Docling parsing"
hint="PDF · PPTX · DOCX · XLSX · PNG · JPG · TIFF · MD · HTML"
accept=".pdf,.pptx,.ppt,.docx,.doc,.xlsx,.png,.jpg,.jpeg,.tiff,.txt,.md,.html"
onFile={onParse}
/>
{loading && <LoadingMsg text="Docling processing document — OCR, table detection, images (30180 sec)…" />}
{parse && (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<StatCard label="File" value={parse.filename.length > 20 ? parse.filename.slice(0, 18) + '…' : parse.filename} sub={parse.status} />
<StatCard label="Processing" value={`${(parse.processing_time_sec || 0).toFixed(1)}s`} sub={`Formats: ${parse.formats_available.join(', ')}`} />
<StatCard label="Pages" value={String(parse.document_structure?.pages ?? parse.stats.pages ?? 0)} icon={Layers} />
<StatCard label="Images" value={String(parse.document_structure?.pictures ?? 0)} icon={Image} accent />
<StatCard label="Tables" value={String(parse.document_structure?.tables ?? 0)} icon={Table2} />
<StatCard label="Text blocks" value={String(parse.document_structure?.text_blocks ?? 0)} sub={`${parse.stats.words?.toLocaleString() ?? 0} words`} />
</div>
<DocStructurePanel structure={parse.document_structure} title="Document analysis" />
{parse.images && parse.images.filter((i) => i.available).length > 0 && (
<ImageGallery images={parse.images} parseId={parse.parse_id} />
)}
{parse.document_structure?.outline && parse.document_structure.outline.length > 0 && (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-3">
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Document outline</h3>
<ul className="space-y-1 text-[11px]">
{parse.document_structure.outline.map((o, i) => (
<li key={i} className="flex gap-2" style={{ paddingLeft: (o.level || 0) * 12 }}>
<span className="shrink-0 rounded bg-docker/20 px-1 font-mono text-[9px] text-docker">{o.type}</span>
<span className="text-foreground-muted">{o.text}</span>
</li>
))}
</ul>
</section>
)}
<div className="flex gap-1">
{(['markdown', 'html'] as const).map((f) => (
<button key={f} type="button" onClick={() => setParseFormat(f)} className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', parseFormat === f ? subTabActive : subTabIdle)}>
{f.toUpperCase()}
</button>
))}
{parse.parse_json_url && (
<a href={parse.parse_json_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
Full JSON
</a>
)}
</div>
{parse.table_preview && parse.table_preview.length > 0 && (
<section>
<h3 className="mb-1 text-[11px] font-semibold uppercase text-foreground-faint">Tables (markdown preview)</h3>
<pre className="scrollbar-thin max-h-40 overflow-auto rounded-lg border border-border bg-surface-overlay p-3 font-mono text-[10px]">
{parse.table_preview.join('\n')}
</pre>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Extracted content</h3>
{parseFormat === 'html' && (parse.content.preview_html || parse.content.html) ? (
<div className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-2">
<div className="rounded bg-white p-4 text-black" dangerouslySetInnerHTML={{ __html: parse.content.preview_html || parse.content.html || '' }} />
</div>
) : (
<pre className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-4 font-mono text-[11px] leading-relaxed text-foreground">
{parse.content.preview_markdown || parse.content.markdown || '(no content)'}
</pre>
)}
</section>
</div>
)}
</div>
)}
{tab === 'reports' && (
<div className="space-y-2">
{reports.length === 0 ? (
<p className="py-12 text-center text-sm text-foreground-muted">No reports yet upload customer data in Maturity Assessment.</p>
) : (
reports.map((r) => (
<a key={r.id} href={`/dq/report/${r.id}`} target="_blank" rel="noreferrer"
className="flex items-center justify-between rounded-lg border border-border bg-surface-overlay/30 px-4 py-3 transition-all hover:border-docker/40 hover:bg-docker/10">
<div>
<p className="text-[12px] font-medium">{r.filename}</p>
<p className="text-[10px] text-foreground-faint">{r.ts} · {r.rows?.toLocaleString()} rows · {r.columns} cols</p>
</div>
<div className="text-right">
<p className={cn('text-xl font-bold', SCORE_COLOR(r.overall_score))}>{r.overall_score}</p>
<p className="text-[10px] text-foreground-muted">{r.maturity_level}</p>
</div>
</a>
))
)}
</div>
)}
</div>
</div>
)
}
function ImageGallery({ images, parseId }: { images: DocImage[]; parseId: string }) {
const available = images.filter((i) => i.available)
const [lightbox, setLightbox] = useState<number | null>(null)
if (!available.length) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Images detected ({images.length}) no embedded export
</h3>
<p className="text-[11px] text-foreground-muted">Re-upload the document to extract images (embedded mode).</p>
</section>
)
}
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<Image className="h-4 w-4 text-docker" />
Images extracted by Docling ({available.length})
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{available.map((img) => (
<button
key={img.index}
type="button"
onClick={() => setLightbox(img.index)}
className="group overflow-hidden rounded-lg border border-border bg-surface-raised text-left transition-all hover:border-docker/50 hover:shadow-docker"
>
<div className="flex aspect-[4/3] items-center justify-center overflow-hidden bg-black/20">
<img
src={img.url || `/dq/parse/${parseId}/image/${img.index}`}
alt={img.label}
className="max-h-full max-w-full object-contain transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
<div className="p-2">
<p className="text-[10px] font-medium text-foreground">#{img.index + 1} {img.label}</p>
<p className="text-[9px] text-foreground-faint">
{img.width && img.height ? `${Math.round(img.width)}×${Math.round(img.height)}` : ''}
{img.dpi ? ` · ${img.dpi}dpi` : ''}
{img.bytes ? ` · ${(img.bytes / 1024).toFixed(0)}KB` : ''}
</p>
</div>
</button>
))}
</div>
{lightbox !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" onClick={() => setLightbox(null)}>
<div className="relative max-h-[90vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
<img
src={`/dq/parse/${parseId}/image/${lightbox}`}
alt={`Image ${lightbox + 1}`}
className="max-h-[85vh] max-w-full rounded-lg object-contain"
/>
<button type="button" onClick={() => setLightbox(null)} className="absolute -top-3 -right-3 rounded-full bg-surface-raised px-2 py-1 text-xs text-foreground"></button>
</div>
</div>
)}
</section>
)
}
function DocStructurePanel({ structure, title }: { structure: DocStructure; title: string }) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">{title}</h3>
<div className="mb-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
{[
{ label: 'Pagina\'s', value: structure.pages, icon: Layers },
{ label: 'Images', value: structure.pictures, icon: Image },
{ label: 'Tables', value: structure.tables, icon: Table2 },
{ label: 'Headings', value: structure.headings },
{ label: 'Paragraphs', value: structure.paragraphs },
{ label: 'Text blocks', value: structure.text_blocks },
].map(({ label, value, icon: Icon }) => (
<div key={label} className="rounded-md border border-border bg-surface-raised p-2 text-center">
{Icon && <Icon className="mx-auto mb-1 h-4 w-4 text-docker" />}
<p className="text-lg font-bold text-foreground">{value}</p>
<p className="text-[9px] text-foreground-faint">{label}</p>
</div>
))}
</div>
{structure.picture_details && structure.picture_details.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Images ({structure.picture_details.length})</p>
<div className="flex flex-wrap gap-1">
{structure.picture_details.map((p) => (
<span key={p.index} className="rounded border border-border bg-surface-raised px-2 py-0.5 text-[9px]">
#{p.index + 1} {p.label} {p.has_image ? '🖼' : ''} {p.captions > 0 ? `(${p.captions} captions)` : ''}
</span>
))}
</div>
</div>
)}
{structure.table_details && structure.table_details.length > 0 && (
<div>
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Tables ({structure.table_details.length})</p>
<div className="space-y-1">
{structure.table_details.map((t) => (
<div key={t.index} className="rounded border border-border bg-surface-raised px-2 py-1 text-[10px] text-foreground-muted">
Table {t.index + 1}: {t.rows}×{t.cols} ({t.cells} cells) {t.preview || '…'}
</div>
))}
</div>
</div>
)}
</section>
)
}
function normalizeChecks(items: DqCheck[]): DqCheck[] {
return items.map((c, i) => ({ ...c, id: c.id || `${c.suite}_${i}` }))
}
function DimensionCard({ dimension: d, expanded, onToggle }: { dimension: Dimension; expanded?: boolean; onToggle?: () => void }) {
return (
<button type="button" onClick={onToggle} className="w-full rounded-lg border border-border bg-surface-overlay/40 p-3 text-left transition-colors hover:border-docker/30">
<div className="mb-1 flex items-center justify-between">
<span className="text-[12px] font-semibold">{d.label}</span>
<span className={cn('text-base font-bold', SCORE_COLOR(d.score))}>{d.score}</span>
</div>
<div className="mb-2 h-2 overflow-hidden rounded-full bg-border">
<div className={cn('h-full rounded-full', BAR_COLOR(d.score))} style={{ width: `${d.score}%` }} />
</div>
<p className="mb-2 text-[10px] text-foreground-faint">{d.description}</p>
<ul className="space-y-0.5 text-[10px] text-foreground-muted">
{d.findings.map((f) => (
<li key={f} className="flex gap-1"><span className="text-docker"></span>{f}</li>
))}
</ul>
{expanded && d.recommended_actions && d.recommended_actions.length > 0 && (
<div className="mt-2 border-t border-border pt-2">
<p className="mb-1 text-[9px] font-semibold uppercase text-foreground-faint">Recommended actions</p>
{d.recommended_actions.map((a) => <p key={a} className="text-[9px] text-warning"> {a}</p>)}
</div>
)}
{!expanded && d.recommended_actions && d.recommended_actions.length > 0 && (
<p className="mt-2 border-t border-border pt-2 text-[9px] text-foreground-faint">Click for {d.recommended_actions.length} recommended action(s)</p>
)}
</button>
)
}
function ColumnTable({ profiles, expandedCol, onToggle }: { profiles: ColumnProfile[]; expandedCol: string | null; onToggle: (n: string | null) => void }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-[11px]">
<thead className="bg-surface-overlay text-[10px] uppercase text-foreground-faint">
<tr>
<th className="px-3 py-2">Column</th><th className="px-3 py-2">Type</th><th className="px-3 py-2">Null%</th>
<th className="px-3 py-2">Unique</th><th className="px-3 py-2">Flags</th>
</tr>
</thead>
<tbody>
{profiles.map((c) => (
<Fragment key={c.name}>
<tr className="cursor-pointer border-t border-border hover:bg-surface-overlay/50" onClick={() => onToggle(expandedCol === c.name ? null : c.name)}>
<td className="px-3 py-2 font-mono text-docker">{c.name}</td>
<td className="px-3 py-2">{c.dtype}</td>
<td className={cn('px-3 py-2', c.null_pct > 10 && 'font-semibold text-warning')}>{c.null_pct}%</td>
<td className="px-3 py-2">{c.unique_count.toLocaleString()}</td>
<td className="px-3 py-2 text-foreground-muted">{c.quality_flags.join(', ') || '—'}</td>
</tr>
{expandedCol === c.name && (
<tr className="border-t border-border bg-surface-overlay/20">
<td colSpan={5} className="px-4 py-2 text-[10px] text-foreground-muted">
{c.sample_values?.length ? <p className="mb-1">Samples: {c.sample_values.join(' · ')}</p> : null}
{c.numeric && <p>Range {c.numeric.min} {c.numeric.max}, μ={c.numeric.mean}, {c.numeric.outliers} outliers</p>}
{c.text && <p>Avg len {c.text.avg_length}, {c.text.empty_strings} empty strings</p>}
{c.top_values?.map((tv) => <span key={tv.value} className="mr-3">{tv.value} ({tv.count})</span>)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)
}
function checkLabel(c: DqCheck): string {
const base = c.expectation || c.name || c.metric || c.check || c.test || c.rule || c.monitor || 'check'
return c.column ? `${base} [${c.column}]` : base
}
function checkStatus(c: DqCheck): string {
return c.status || (c.success === false ? 'fail' : c.success ? 'pass' : c.outcome || 'unknown')
}
function checkDetail(c: DqCheck): string {
return c.result || c.detail || ''
}
function CheckTable({ title, checks, expandedId, onToggle }: { title: string; checks: DqCheck[]; expandedId: string | null; onToggle: (id: string | null) => void }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<p className="border-b border-border bg-surface-overlay px-3 py-2 text-[11px] font-semibold capitalize">{title}</p>
<table className="w-full text-[10px]">
<tbody>
{checks.map((c) => {
const id = c.id || checkLabel(c)
const status = checkStatus(c)
const viol = c.violations
return (
<Fragment key={id}>
<tr className="cursor-pointer border-t border-border hover:bg-surface-overlay/50" onClick={() => onToggle(expandedId === id ? null : id)}>
<td className="px-3 py-1.5">
<span className={cn('mr-2 rounded px-1.5 py-0.5 text-[9px] font-bold uppercase',
status === 'pass' ? 'bg-success/20 text-success' : status === 'warn' ? 'bg-warning/20 text-warning' : 'bg-danger/20 text-danger')}>
{status}
</span>
{checkLabel(c)}
{viol?.violation_count ? <span className="ml-2 text-[9px] text-danger">{viol.violation_count} violations</span> : null}
</td>
<td className="px-3 py-1.5 text-foreground-muted">{checkDetail(c)}</td>
</tr>
{expandedId === id && (
<tr className="border-t border-border bg-surface-overlay/20">
<td colSpan={2} className="px-4 py-3 text-[10px]">
<p className="mb-2 font-semibold text-docker">Error location &amp; samples</p>
{viol?.violation_count ? (
<>
<p className="text-foreground-muted">{viol.violation_count} rows affected ({viol.violation_pct}%){viol.location_hint ? ` · ${viol.location_hint}` : ''}</p>
{viol.sample_rows && viol.sample_rows.length > 0 && (
<div className="mt-2 overflow-x-auto rounded border border-border">
<table className="w-full text-[9px]">
<thead><tr className="bg-surface-overlay"><th className="px-2 py-1">Row #</th>{Object.keys(viol.sample_rows[0].values).map((k) => <th key={k} className="px-2 py-1">{k}</th>)}</tr></thead>
<tbody>
{viol.sample_rows.map((s) => (
<tr key={s.row_index} className="border-t border-border/50">
<td className="px-2 py-1 font-mono text-warning">{s.row_index}</td>
{Object.values(s.values).map((v, j) => <td key={j} className="max-w-[120px] truncate px-2 py-1">{v === null ? 'NULL' : String(v)}</td>)}
</tr>
))}
</tbody>
</table>
</div>
)}
</>
) : (
<p className="text-success">No row-level violations check passed.</p>
)}
{c.meta && <pre className="mt-2 rounded bg-black/30 p-2 font-mono text-[9px]">{JSON.stringify(c.meta, null, 2)}</pre>}
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)
}
function CapCard({ title, items, icon: Icon }: { title: string; items: string[]; icon: typeof Layers }) {
return (
<div className="rounded-lg border border-border bg-surface-raised/80 p-2.5">
<div className="mb-1 flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-docker" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-foreground-faint">{title}</span>
</div>
<p className="text-[10px] leading-relaxed text-foreground-muted">{items.join(' · ')}</p>
</div>
)
}
function UploadZone({ label, hint, accept, loading, onFile }: { label: string; hint: string; accept: string; loading: boolean; onFile: (f: File) => void }) {
return (
<label className={cn('flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border/80 bg-surface-overlay/30 px-8 py-10 transition-all hover:border-docker/50 hover:bg-docker/5', loading && 'pointer-events-none opacity-50')}>
<Upload className="mb-3 h-10 w-10 text-docker opacity-60" />
<p className="text-[13px] font-medium text-foreground">{label}</p>
<p className="mt-1 text-[10px] text-foreground-faint">{hint}</p>
<input type="file" accept={accept} className="hidden" disabled={loading} onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
</label>
)
}
function LoadingMsg({ text }: { text: string }) {
return (
<div className="flex items-center justify-center gap-3 rounded-lg border border-docker/30 bg-docker/5 py-10 text-sm text-foreground-muted">
<Loader2 className="h-6 w-6 animate-spin text-docker" />
{text}
</div>
)
}
function StatCard({ label, value, sub, accent, warn, icon: Icon }: { label: string; value: string; sub?: string; accent?: boolean; warn?: boolean; icon?: typeof Image }) {
return (
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
<div className="flex items-center gap-1">
{Icon && <Icon className="h-3.5 w-3.5 text-docker" />}
<p className="text-[9px] uppercase tracking-wider text-foreground-faint">{label}</p>
</div>
<p className={cn('text-xl font-bold', accent ? 'text-docker' : warn ? 'text-warning' : 'text-foreground')}>{value}</p>
{sub && <p className="text-[10px] text-foreground-muted">{sub}</p>}
</div>
)
}