2026-06-25 00:28:23 +00:00
|
|
|
|
import { useCallback, useEffect, useState, Fragment } from 'react'
|
|
|
|
|
|
import {
|
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
|
FileSearch,
|
|
|
|
|
|
FileText,
|
|
|
|
|
|
Image,
|
|
|
|
|
|
Layers,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Table2,
|
|
|
|
|
|
Upload,
|
|
|
|
|
|
XCircle,
|
2026-06-29 17:43:37 +00:00
|
|
|
|
Gauge,
|
2026-06-25 00:28:23 +00:00
|
|
|
|
} from 'lucide-react'
|
|
|
|
|
|
import { cn } from '../../lib/utils'
|
|
|
|
|
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
2026-06-29 17:43:37 +00:00
|
|
|
|
import { DqMonitoringPanel } from './DqMonitoringPanel'
|
2026-06-25 00:28:23 +00:00
|
|
|
|
|
|
|
|
|
|
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 }[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 10:00:07 +00:00
|
|
|
|
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 }
|
2026-06-25 00:28:23 +00:00
|
|
|
|
|
|
|
|
|
|
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 }[]
|
2026-06-25 10:00:07 +00:00
|
|
|
|
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
|
2026-06-25 00:28:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 17:43:37 +00:00
|
|
|
|
type Tab = 'assess' | 'docling' | 'reports' | 'monitoring'
|
2026-06-25 00:28:23 +00:00
|
|
|
|
|
|
|
|
|
|
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() {
|
2026-06-29 17:43:37 +00:00
|
|
|
|
const [tab, setTab] = useState<Tab>('monitoring')
|
2026-06-25 00:28:23 +00:00
|
|
|
|
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)
|
2026-06-25 10:00:07 +00:00
|
|
|
|
const [expandedCheck, setExpandedCheck] = useState<string | null>(null)
|
|
|
|
|
|
const [expandedDim, setExpandedDim] = useState<string | null>(null)
|
|
|
|
|
|
const [activeTool, setActiveTool] = useState<string>('great_expectations')
|
2026-06-25 00:28:23 +00:00
|
|
|
|
|
|
|
|
|
|
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 }[] = [
|
2026-06-29 17:43:37 +00:00
|
|
|
|
{ id: 'monitoring', label: 'Live Monitoring', icon: Gauge },
|
2026-06-25 00:28:23 +00:00
|
|
|
|
{ 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} />
|
2026-06-25 10:00:07 +00:00
|
|
|
|
<CapCard title="Data Quality Tools" items={['Great Expectations', 'Soda Core', 'Deequ', 'Pandera', 'dbt', 'Monte Carlo', 'Affirm', 'Pandas']} icon={CheckCircle2} />
|
2026-06-25 00:28:23 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-29 17:43:37 +00:00
|
|
|
|
{tab === 'monitoring' && <DqMonitoringPanel />}
|
|
|
|
|
|
|
2026-06-25 00:28:23 +00:00
|
|
|
|
{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`} />
|
2026-06-25 10:00:07 +00:00
|
|
|
|
<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} />
|
2026-06-25 00:28:23 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-25 10:00:07 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-25 00:28:23 +00:00
|
|
|
|
<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>
|
2026-06-25 10:00:07 +00:00
|
|
|
|
<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>
|
2026-06-25 00:28:23 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-25 10:00:07 +00:00
|
|
|
|
<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>
|
2026-06-25 00:28:23 +00:00
|
|
|
|
|
|
|
|
|
|
<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) => (
|
2026-06-25 10:00:07 +00:00
|
|
|
|
<DimensionCard key={d.id} dimension={d} expanded={expandedDim === d.id} onToggle={() => setExpandedDim(expandedDim === d.id ? null : d.id)} />
|
2026-06-25 00:28:23 +00:00
|
|
|
|
))}
|
|
|
|
|
|
</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.
|
2026-06-25 10:00:07 +00:00
|
|
|
|
Output: Markdown, HTML, JSON with pages, images, tables and document outline.
|
2026-06-25 00:28:23 +00:00
|
|
|
|
</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 (30–180 sec)…" />}
|
|
|
|
|
|
|
|
|
|
|
|
{parse && (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
|
2026-06-25 10:00:07 +00:00
|
|
|
|
<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(', ')}`} />
|
2026-06-25 00:28:23 +00:00
|
|
|
|
<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">
|
2026-06-25 10:00:07 +00:00
|
|
|
|
Images detected ({images.length}) — no embedded export
|
2026-06-25 00:28:23 +00:00
|
|
|
|
</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" />
|
2026-06-25 10:00:07 +00:00
|
|
|
|
Images extracted by Docling ({available.length})
|
2026-06-25 00:28:23 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 10:00:07 +00:00
|
|
|
|
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 }) {
|
2026-06-25 00:28:23 +00:00
|
|
|
|
return (
|
2026-06-25 10:00:07 +00:00
|
|
|
|
<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">
|
2026-06-25 00:28:23 +00:00
|
|
|
|
<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>
|
2026-06-25 10:00:07 +00:00
|
|
|
|
{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>
|
2026-06-25 00:28:23 +00:00
|
|
|
|
)}
|
2026-06-25 10:00:07 +00:00
|
|
|
|
{!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>
|
2026-06-25 00:28:23 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 10:00:07 +00:00
|
|
|
|
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 }) {
|
2026-06-25 00:28:23 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="overflow-x-auto rounded-lg border border-border">
|
2026-06-25 10:00:07 +00:00
|
|
|
|
<p className="border-b border-border bg-surface-overlay px-3 py-2 text-[11px] font-semibold capitalize">{title}</p>
|
2026-06-25 00:28:23 +00:00
|
|
|
|
<table className="w-full text-[10px]">
|
|
|
|
|
|
<tbody>
|
2026-06-25 10:00:07 +00:00
|
|
|
|
{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 & 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-06-25 00:28:23 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|