247 lines
13 KiB
TypeScript
247 lines
13 KiB
TypeScript
|
|
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>
|
||
|
|
)
|
||
|
|
}
|