Files
atc-agents/ui/src/components/features/GovernanceOwnershipView.tsx
T

247 lines
13 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState } from 'react'
import { UserCircle, RefreshCw, Loader2, AlertTriangle, BookOpen, ShieldCheck, Check, X } from 'lucide-react'
import { cn } from '../../lib/utils'
type Pii = { pii_count: number; all_masked: boolean; masked: number; unmasked: number }
type DsRow = {
key: string; label: string; engine: string; color: string; table: string
domain?: string; owner?: string | null; steward?: string | null; team?: string | null
tier?: string | null; classification?: string | null; updated_at?: string | null
orphan: boolean; pii: Pii
}
type Summary = { total: number; orphans: number; stewarded: number; owned: number }
type GUser = { id: string | null; name: string; display: string; type: string }
type Term = { name: string; description: string; domain?: string; related?: string[] }
const TIERS = ['Tier1', 'Tier2', 'Tier3']
const CLASSES = ['Public', 'Internal', 'Confidential', 'Restricted']
export function GovernanceOwnershipView() {
const [rows, setRows] = useState<DsRow[]>([])
const [summary, setSummary] = useState<Summary | null>(null)
const [users, setUsers] = useState<GUser[]>([])
const [glossary, setGlossary] = useState<Term[]>([])
const [glossarySource, setGlossarySource] = useState('')
const [loading, setLoading] = useState(true)
const [omConnected, setOmConnected] = useState(false)
const [editKey, setEditKey] = useState<string | null>(null)
const [form, setForm] = useState<{ owner: string; steward: string; team: string; tier: string; classification: string }>(
{ owner: '', steward: '', team: '', tier: '', classification: '' },
)
const [saving, setSaving] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [d, u, g] = await Promise.all([
fetch('/api/governance/datasets').then((r) => r.json()),
fetch('/api/governance/users').then((r) => r.json()),
fetch('/api/governance/glossary').then((r) => r.json()),
])
setRows(d.datasets || [])
setSummary(d.summary || null)
setOmConnected(!!d.om_connected)
setUsers(u.users || [])
setGlossary(g.terms || [])
setGlossarySource(g.source || '')
} catch { /* */ } finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const openEdit = (r: DsRow) => {
setEditKey(r.key)
setForm({ owner: r.owner || '', steward: r.steward || '', team: r.team || '', tier: r.tier || '', classification: r.classification || '' })
}
const save = async () => {
if (!editKey) return
setSaving(true)
try {
await fetch('/api/governance/assign', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: editKey, ...form }),
})
setEditKey(null)
await load()
} catch { /* */ } finally {
setSaving(false)
}
}
const people = users.filter((u) => u.type === 'user')
const teams = users.filter((u) => u.type === 'team')
return (
<div className="scrollbar-thin flex h-full min-h-0 flex-col gap-2 overflow-y-auto">
{/* KPIs */}
<div className="grid shrink-0 grid-cols-2 gap-2 md:grid-cols-4">
<Kpi icon={UserCircle} label="Owned" value={summary ? `${summary.owned}/${summary.total}` : '—'} accent="#34d399" />
<Kpi icon={AlertTriangle} label="Orphan datasets" value={summary ? String(summary.orphans) : '—'} accent={summary && summary.orphans ? '#f87171' : '#34d399'} />
<Kpi icon={ShieldCheck} label="Stewarded" value={summary ? String(summary.stewarded) : '—'} accent="#60a5fa" />
<Kpi icon={BookOpen} label="Glossary terms" value={String(glossary.length)} accent="#a78bfa" />
</div>
<div className="flex shrink-0 items-center gap-2 px-1">
<span className="text-[10px] text-foreground-faint">
OpenMetadata {omConnected ? 'connected' : 'offline'} · assignments stored locally{omConnected ? ' + synced to OM' : ''}
</span>
<button type="button" onClick={load} className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[10px] text-foreground-muted hover:bg-surface-overlay">
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} Refresh
</button>
</div>
{/* ownership matrix */}
<div className="panel min-h-0 shrink-0 overflow-x-auto p-0">
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-border text-left text-foreground-faint">
<th className="px-3 py-2 font-semibold">Dataset</th>
<th className="px-3 py-2 font-semibold">Owner</th>
<th className="px-3 py-2 font-semibold">Steward</th>
<th className="px-3 py-2 font-semibold">Team</th>
<th className="px-3 py-2 font-semibold">Tier</th>
<th className="px-3 py-2 font-semibold">PII</th>
<th className="px-3 py-2 font-semibold" />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.key} className={cn('border-b border-border/50', r.orphan && 'bg-rose-500/5')}>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: r.color }} />
<span className="font-medium text-foreground">{r.label}</span>
</div>
<span className="font-mono text-[8px] text-foreground-faint">{r.table}</span>
</td>
<td className="px-3 py-2">
{r.owner ? <span className="text-foreground">{r.owner}</span>
: <span className="flex items-center gap-1 text-rose-400"><AlertTriangle className="h-3 w-3" /> unassigned</span>}
</td>
<td className="px-3 py-2 text-foreground-muted">{r.steward || '—'}</td>
<td className="px-3 py-2 text-foreground-muted">{r.team || '—'}</td>
<td className="px-3 py-2">
{r.tier ? <span className="rounded bg-surface-overlay px-1.5 py-0.5 text-foreground-muted">{r.tier}</span> : '—'}
</td>
<td className="px-3 py-2">
{r.pii.pii_count > 0 ? (
<span className={cn('rounded px-1.5 py-0.5', r.pii.unmasked === 0 ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300')}>
{r.pii.masked}/{r.pii.pii_count} masked
</span>
) : <span className="text-foreground-faint">none</span>}
</td>
<td className="px-3 py-2 text-right">
<button type="button" onClick={() => openEdit(r)} className="rounded border border-border px-2 py-0.5 text-[9px] text-foreground-muted hover:bg-surface-overlay">
Assign
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* glossary */}
<h2 className="mt-1 shrink-0 px-1 text-[10px] font-semibold uppercase tracking-widest text-foreground-muted">
Business glossary {glossarySource && <span className="text-foreground-faint">· {glossarySource}</span>}
</h2>
<div className="grid shrink-0 gap-2 pb-2 md:grid-cols-2 lg:grid-cols-3">
{glossary.map((t) => (
<div key={t.name} className="panel p-2.5">
<div className="flex items-center gap-1.5">
<BookOpen className="h-3.5 w-3.5 text-docker" />
<span className="text-[11px] font-semibold text-foreground">{t.name}</span>
{t.domain && <span className="ml-auto rounded bg-surface-overlay px-1.5 py-0.5 text-[8px] text-foreground-faint">{t.domain}</span>}
</div>
<p className="mt-1 text-[10px] text-foreground-muted">{t.description}</p>
{t.related && t.related.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{t.related.map((rl) => <span key={rl} className="rounded bg-surface-overlay px-1.5 py-0.5 font-mono text-[8px] text-foreground-faint">{rl}</span>)}
</div>
)}
</div>
))}
</div>
{/* assign modal */}
{editKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEditKey(null)}>
<div className="panel w-full max-w-md p-4" onClick={(e) => e.stopPropagation()}>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-foreground">Assign ownership {rows.find((r) => r.key === editKey)?.label}</h3>
<button type="button" onClick={() => setEditKey(null)} className="text-foreground-muted hover:text-foreground"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-2.5">
<Field label="Owner">
<select value={form.owner} onChange={(e) => setForm({ ...form, owner: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""> unassigned </option>
{people.map((u) => <option key={u.name} value={u.display}>{u.display}</option>)}
</select>
</Field>
<Field label="Steward">
<select value={form.steward} onChange={(e) => setForm({ ...form, steward: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""> none </option>
{people.map((u) => <option key={u.name} value={u.display}>{u.display}</option>)}
</select>
</Field>
<Field label="Team">
<select value={form.team} onChange={(e) => setForm({ ...form, team: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""> none </option>
{teams.map((u) => <option key={u.name} value={u.display}>{u.display}</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Tier">
<select value={form.tier} onChange={(e) => setForm({ ...form, tier: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""></option>
{TIERS.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<Field label="Classification">
<select value={form.classification} onChange={(e) => setForm({ ...form, classification: e.target.value })} className="w-full rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-foreground">
<option value=""></option>
{CLASSES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={() => setEditKey(null)} className="rounded border border-border px-3 py-1.5 text-[11px] text-foreground-muted hover:bg-surface-overlay">Cancel</button>
<button type="button" onClick={save} disabled={saving} className="inline-flex items-center gap-1.5 rounded bg-docker px-3 py-1.5 text-[11px] font-medium text-white hover:bg-docker/90 disabled:opacity-60">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} Save
</button>
</div>
</div>
</div>
)}
</div>
)
}
function Kpi({ icon: Icon, label, value, accent }: { icon: typeof UserCircle; label: string; value: string; accent: string }) {
return (
<div className="panel flex items-center gap-3 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: `${accent}1f`, color: accent }}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[9px] font-semibold uppercase tracking-wider text-foreground-muted">{label}</p>
<p className="truncate text-lg font-bold leading-tight text-foreground">{value}</p>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">{label}</label>
{children}
</div>
)
}