mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-15 04:00:33 +00:00
Merge pull request #557 from Hawksight-AI/feat/ui-redesign-semantica-explorer
# feat: Redesign all workspace UIs with consistent design system + bug fixes
This commit is contained in:
+1019
-503
File diff suppressed because it is too large
Load Diff
@@ -1,77 +1,25 @@
|
||||
/**
|
||||
* src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Scale, Search } from "lucide-react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { opacity: 0.45; }
|
||||
50% { opacity: 0.85; }
|
||||
100% { opacity: 0.45; }
|
||||
}
|
||||
.skeleton-item {
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
`;
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Scale, Search, ArrowRight, Info } from "lucide-react";
|
||||
|
||||
type OutcomeKind = "approved" | "rejected" | "deferred" | "pending" | string;
|
||||
|
||||
function outcomeStyle(outcome: string): { color: string; bg: string; border: string } {
|
||||
const lower = (outcome ?? "").toLowerCase();
|
||||
if (lower.includes("approv") || lower.includes("accept"))
|
||||
return { color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" };
|
||||
if (lower.includes("reject") || lower.includes("denied") || lower.includes("fail"))
|
||||
return { color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" };
|
||||
if (lower.includes("defer") || lower.includes("pending") || lower.includes("review"))
|
||||
return { color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" };
|
||||
return { color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" };
|
||||
function outcomeColor(outcome: string) {
|
||||
const o = (outcome ?? "").toLowerCase();
|
||||
if (o.includes("approv") || o.includes("accept")) return { color: "#6ee7b7", bg: "var(--ws-green-soft)", border: "rgba(76,195,138,0.3)" };
|
||||
if (o.includes("reject") || o.includes("denied") || o.includes("fail")) return { color: "#fca5a5", bg: "var(--ws-red-soft)", border: "rgba(255,123,114,0.3)" };
|
||||
if (o.includes("defer") || o.includes("pending") || o.includes("review")) return { color: "#fbbf24", bg: "var(--ws-amber-soft)", border: "rgba(242,182,109,0.3)" };
|
||||
return { color: "var(--ws-text-muted)", bg: "rgba(255,255,255,0.04)", border: "rgba(255,255,255,0.1)" };
|
||||
}
|
||||
|
||||
function OutcomeBadge({ outcome }: { outcome: OutcomeKind }) {
|
||||
const style = outcomeStyle(outcome);
|
||||
const c = outcomeColor(outcome);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: style.color,
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "inline-block", padding: "2px 8px", borderRadius: 999, fontSize: 10, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: c.color, background: c.bg, border: `1px solid ${c.border}` }}>
|
||||
{outcome || "unknown"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonList() {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-item" style={{ height: 62 }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Causal Flow Diagram ──────────────────────────────────────────── */
|
||||
|
||||
interface ChainStep {
|
||||
id: string;
|
||||
relationship: string;
|
||||
@@ -80,255 +28,178 @@ interface ChainStep {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function RelationshipPill({ label }: { label: string }) {
|
||||
const NODE_ACCENTS = ["#4aa3ff","#4cc38a","#f2b66d","#c084fc","#ff7b72","#38bdf8","#a78bfa"];
|
||||
|
||||
function ChainNode({ step, index }: { step: ChainStep; index: number }) {
|
||||
const accent = NODE_ACCENTS[index % NODE_ACCENTS.length];
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0, position: "relative", margin: "0 auto" }}>
|
||||
{/* Connector line top */}
|
||||
<div style={{ width: 2, height: 12, background: "rgba(88,166,255,0.25)" }} />
|
||||
{/* Pill */}
|
||||
<div
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: "#79c0ff",
|
||||
background: "rgba(88,166,255,0.1)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 260,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{/* Connector line bottom + arrow */}
|
||||
<div style={{ width: 2, height: 10, background: "rgba(88,166,255,0.25)" }} />
|
||||
<div style={{ width: 0, height: 0, borderLeft: "5px solid transparent", borderRight: "5px solid transparent", borderTop: "6px solid rgba(88,166,255,0.4)" }} />
|
||||
<div style={{ padding: "14px 16px", borderRadius: "var(--ws-radius)", background: "var(--ws-surface)", border: `1px solid ${accent}28`, borderLeft: `3px solid ${accent}`, position: "relative" }}>
|
||||
{step.type && (
|
||||
<div style={{ fontFamily: "monospace", fontSize: 10, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: accent, marginBottom: 5 }}>
|
||||
{step.type}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: "var(--ws-text)", fontSize: 13, fontWeight: 600 }}>{step.content || step.id}</div>
|
||||
{step.id && step.id !== step.content && (
|
||||
<div style={{ fontFamily: "monospace", fontSize: 10, color: "var(--ws-text-dim)", marginTop: 3 }}>{step.id}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainNodeCard({ step, index }: { step: ChainStep; index: number }) {
|
||||
const COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff"];
|
||||
const color = COLORS[index % COLORS.length];
|
||||
|
||||
function RelEdge({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
padding: "14px 16px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.5))",
|
||||
border: `1px solid ${color}33`,
|
||||
boxShadow: `0 0 0 1px ${color}11, inset 0 1px 0 rgba(255,255,255,0.04)`,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8, height: 8, borderRadius: "50%",
|
||||
background: color,
|
||||
boxShadow: `0 0 8px ${color}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{step.type ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", color,
|
||||
}}
|
||||
>
|
||||
{step.type}
|
||||
</span>
|
||||
) : null}
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0, padding: "2px 0" }}>
|
||||
<div style={{ width: 2, height: 10, background: "var(--ws-border)" }} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "3px 10px", borderRadius: 999, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", maxWidth: 260 }}>
|
||||
<ArrowRight size={10} color="var(--ws-accent)" />
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: "var(--ws-accent)", letterSpacing: "0.06em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 14, fontWeight: 600 }}>
|
||||
{step.content || step.id}
|
||||
</div>
|
||||
{step.id && step.id !== step.content ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", marginTop: 3 }}>{step.id}</div>
|
||||
) : null}
|
||||
<div style={{ width: 2, height: 10, background: "var(--ws-border)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CausalFlowDiagram({ chain, loading }: { chain: ChainStep[]; loading: boolean }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton-item" style={{ height: 68 }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chain.length === 0) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 24px", color: "#8b949e", fontSize: 13 }}>
|
||||
No causal chain steps found for this decision.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CausalFlow({ chain, loading }: { chain: ChainStep[]; loading: boolean }) {
|
||||
if (loading) return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[1,2,3].map((i) => <div key={i} className="ws-skeleton" style={{ height: 68 }} />)}
|
||||
</div>
|
||||
);
|
||||
if (!chain.length) return (
|
||||
<div className="ws-empty">
|
||||
<div className="ws-empty-icon"><Info size={28} /></div>
|
||||
<div className="ws-empty-title">No chain steps</div>
|
||||
<div className="ws-empty-body">No causal chain steps were found for this decision.</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "stretch" }}>
|
||||
{chain.map((step, index) => (
|
||||
<div key={`${step.id}-${index}`} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<ChainNodeCard step={step} index={index} />
|
||||
{index < chain.length - 1 ? (
|
||||
<RelationshipPill label={chain[index + 1]?.relationship || "→"} />
|
||||
) : null}
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{chain.map((step, i) => (
|
||||
<div key={`${step.id}-${i}`}>
|
||||
<ChainNode step={step} index={i} />
|
||||
{i < chain.length - 1 && <RelEdge label={chain[i + 1]?.relationship || "→"} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Workspace ──────────────────────────────────────────────── */
|
||||
|
||||
export function DecisionWorkspace() {
|
||||
const [decisions, setDecisions] = useState<any[]>([]);
|
||||
const [selectedDecision, setSelectedDecision] = useState<any | null>(null);
|
||||
const [decisions, setDecisions] = useState<{ decision_id: string; category?: string; outcome?: string }[]>([]);
|
||||
const [selected, setSelected] = useState<{ decision_id: string; category?: string; outcome?: string } | null>(null);
|
||||
const [chain, setChain] = useState<ChainStep[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [chainLoading, setChainLoading] = useState(false);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
// Tracks the active chain request so stale responses from rapid selections are ignored.
|
||||
const chainCtrlRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const ctrl = new AbortController();
|
||||
setListLoading(true);
|
||||
fetch("/api/decisions", { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`Failed to load decisions: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
fetch("/api/decisions", { signal: ctrl.signal })
|
||||
.then((r) => r.ok ? r.json() : Promise.reject(r.status))
|
||||
.then((data) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setDecisions(data);
|
||||
if (data.length > 0) void handleSelectDecision(data[0]);
|
||||
if (data.length > 0) void loadChain(data[0]);
|
||||
})
|
||||
.catch((err) => { if (err.name !== "AbortError") console.error(err); })
|
||||
.finally(() => setListLoading(false));
|
||||
return () => controller.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setListLoading(false);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filteredDecisions = useMemo(() => {
|
||||
if (!filterQuery.trim()) return decisions;
|
||||
const q = filterQuery.toLowerCase();
|
||||
return decisions.filter(
|
||||
(d) =>
|
||||
String(d.decision_id ?? "").toLowerCase().includes(q) ||
|
||||
String(d.category ?? "").toLowerCase().includes(q) ||
|
||||
String(d.outcome ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [decisions, filterQuery]);
|
||||
// Cancel any in-flight chain request when the workspace unmounts.
|
||||
useEffect(() => () => { chainCtrlRef.current?.abort(); }, []);
|
||||
|
||||
const handleSelectDecision = async (d: any) => {
|
||||
setSelectedDecision(d);
|
||||
setLoading(true);
|
||||
async function loadChain(d: { decision_id: string; category?: string; outcome?: string }) {
|
||||
chainCtrlRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
chainCtrlRef.current = ctrl;
|
||||
|
||||
setSelected(d);
|
||||
setChainLoading(true);
|
||||
setChain([]);
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const res = await fetch(`/api/decisions/${encodeURIComponent(d.decision_id)}/chain`, { signal: controller.signal });
|
||||
if (!res.ok) throw new Error(`Failed to load chain: ${res.status}`);
|
||||
const res = await fetch(`/api/decisions/${encodeURIComponent(d.decision_id)}/chain`, { signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
setChain(data.chain || []);
|
||||
if (!ctrl.signal.aborted) setChain(data.chain || []);
|
||||
} catch (e) {
|
||||
if ((e as DOMException).name !== "AbortError") console.error(e);
|
||||
if (e instanceof Error && e.name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!ctrl.signal.aborted) setChainLoading(false);
|
||||
}
|
||||
return () => controller.abort();
|
||||
};
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.toLowerCase().trim();
|
||||
if (!q) return decisions;
|
||||
return decisions.filter((d) =>
|
||||
d.decision_id.toLowerCase().includes(q) ||
|
||||
(d.category ?? "").toLowerCase().includes(q) ||
|
||||
(d.outcome ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [decisions, filter]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: "100%", background: "#0d1117", overflow: "hidden" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Left Column — Decision List */}
|
||||
<div
|
||||
className="glass-panel"
|
||||
style={{
|
||||
width: 300,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 0,
|
||||
border: "none",
|
||||
borderRight: "1px solid rgba(88,166,255,0.16)",
|
||||
}}
|
||||
>
|
||||
{/* List header */}
|
||||
<div style={{ padding: "20px 20px 14px", borderBottom: "1px solid rgba(255,255,255,0.06)", flexShrink: 0 }}>
|
||||
<div className="ws-page" style={{ flexDirection: "row" }}>
|
||||
{/* ── Sidebar list ── */}
|
||||
<div className="ws-sidebar" style={{ width: 290 }}>
|
||||
<div className="ws-sidebar-header">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<Scale size={16} color="#4aa3ff" />
|
||||
<h2 style={{ color: "#ebf3ff", margin: 0, fontSize: 15, fontWeight: 700 }}>Decisions</h2>
|
||||
{decisions.length > 0 ? (
|
||||
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>{decisions.length}</span>
|
||||
) : null}
|
||||
<div style={{ width: 30, height: 30, borderRadius: 9, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
|
||||
<Scale size={15} />
|
||||
</div>
|
||||
<div style={{ color: "var(--ws-text)", fontSize: 14, fontWeight: 700 }}>Decisions</div>
|
||||
{decisions.length > 0 && !listLoading && (
|
||||
<span className="ws-pill ws-pill--mono" style={{ marginLeft: "auto" }}>{decisions.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter input */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<Search
|
||||
size={13}
|
||||
color="#8b949e"
|
||||
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
|
||||
/>
|
||||
<Search size={12} color="var(--ws-text-dim)" style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }} />
|
||||
<input
|
||||
className="ws-input"
|
||||
type="text"
|
||||
placeholder="Filter decisions…"
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
style={filterInputStyle}
|
||||
placeholder="Filter by ID, category, outcome…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
style={{ paddingLeft: 30, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
|
||||
<div className="ws-sidebar-body">
|
||||
{listLoading ? (
|
||||
<SkeletonList />
|
||||
) : filteredDecisions.length === 0 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 13, textAlign: "center", padding: "32px 12px" }}>
|
||||
{decisions.length === 0 ? "No decisions available." : "No decisions match your filter."}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{[1,2,3,4,5].map((i) => <div key={i} className="ws-skeleton" style={{ height: 58 }} />)}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="ws-empty" style={{ padding: "28px 12px" }}>
|
||||
<div className="ws-empty-title">{decisions.length === 0 ? "No decisions" : "No matches"}</div>
|
||||
<div className="ws-empty-body">{decisions.length === 0 ? "No decisions available in the graph." : "Adjust your filter."}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{filteredDecisions.map((d) => {
|
||||
const isActive = selectedDecision?.decision_id === d.decision_id;
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{filtered.map((d) => {
|
||||
const active = selected?.decision_id === d.decision_id;
|
||||
return (
|
||||
<button
|
||||
key={d.decision_id}
|
||||
onClick={() => void handleSelectDecision(d)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
background: isActive
|
||||
? "rgba(74,163,255,0.15)"
|
||||
: "rgba(255,255,255,0.02)",
|
||||
border: isActive
|
||||
? "1px solid rgba(74,163,255,0.32)"
|
||||
: "1px solid rgba(255,255,255,0.06)",
|
||||
color: isActive ? "#ffffff" : "#c6d4e3",
|
||||
transition: "all 160ms ease",
|
||||
}}
|
||||
className={`ws-list-item${active ? " ws-list-item--active" : ""}`}
|
||||
onClick={() => void loadChain(d)}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>{d.decision_id}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||
{d.category ? (
|
||||
<span style={{ fontSize: 11, color: "#8b949e" }}>{d.category}</span>
|
||||
) : null}
|
||||
{d.outcome ? <OutcomeBadge outcome={d.outcome} /> : null}
|
||||
<div style={{ fontWeight: 700, fontSize: 12, marginBottom: 5, color: active ? "#e8f6ff" : "var(--ws-text)" }}>
|
||||
{d.decision_id}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 5, flexWrap: "wrap" }}>
|
||||
{d.category && <span style={{ fontSize: 10, color: "var(--ws-text-dim)" }}>{d.category}</span>}
|
||||
{d.outcome && <OutcomeBadge outcome={d.outcome} />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -338,70 +209,48 @@ export function DecisionWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column — Decision Detail */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Radial accent */}
|
||||
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse at top right, rgba(88,166,255,0.04), transparent 55%)", pointerEvents: "none", zIndex: 0 }} />
|
||||
{/* ── Detail pane ── */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", position: "relative" }}>
|
||||
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse 60% 40% at 70% 20%, rgba(74,163,255,0.04), transparent 55%)", pointerEvents: "none" }} />
|
||||
|
||||
{selectedDecision ? (
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px", position: "relative", zIndex: 1 }}>
|
||||
{selected ? (
|
||||
<div className="ws-scroll ws-padded ws-animate-in" style={{ position: "relative", zIndex: 1 }}>
|
||||
{/* Decision header */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: 6 }}>
|
||||
Decision ID
|
||||
</div>
|
||||
<h1 style={{ color: "#ffffff", fontSize: 24, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 8px 0", wordBreak: "break-word" }}>
|
||||
{selectedDecision.decision_id}
|
||||
</h1>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12, flexWrap: "wrap", marginBottom: 10 }}>
|
||||
<div>
|
||||
<div className="ws-eyebrow" style={{ marginBottom: 6 }}>Decision Record</div>
|
||||
<h2 className="ws-title">{selected.decision_id}</h2>
|
||||
</div>
|
||||
{selectedDecision.outcome ? <OutcomeBadge outcome={selectedDecision.outcome} /> : null}
|
||||
{selected.outcome && <OutcomeBadge outcome={selected.outcome} />}
|
||||
</div>
|
||||
|
||||
{selectedDecision.category ? (
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 10px", borderRadius: 999, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)", color: "#8b949e", fontSize: 12 }}>
|
||||
{selectedDecision.category}
|
||||
</div>
|
||||
) : null}
|
||||
{selected.category && (
|
||||
<span className="ws-pill ws-pill--mono">{selected.category}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Causal Chain */}
|
||||
<div className="glass-panel" style={{ padding: 24, borderRadius: 16 }}>
|
||||
{/* Chain section */}
|
||||
<div className="ws-card" style={{ padding: 22 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 20 }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "linear-gradient(135deg, #4aa3ff, #f2b66d)", boxShadow: "0 0 10px rgba(74,163,255,0.4)" }} />
|
||||
<h3 style={{ color: "#e6edf3", margin: 0, fontSize: 14, fontWeight: 700, letterSpacing: "0.02em" }}>
|
||||
Causal Chain
|
||||
</h3>
|
||||
{chain.length > 0 && !loading ? (
|
||||
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>
|
||||
{chain.length} step{chain.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
<div style={{ width: 8, height: 8, borderRadius: 999, background: "linear-gradient(135deg, var(--ws-accent), var(--ws-amber))", boxShadow: "0 0 10px rgba(74,163,255,0.4)" }} />
|
||||
<div style={{ color: "var(--ws-text)", fontSize: 14, fontWeight: 700 }}>Causal Chain</div>
|
||||
{chain.length > 0 && !chainLoading && (
|
||||
<span className="ws-pill ws-pill--accent" style={{ marginLeft: "auto" }}>{chain.length} step{chain.length !== 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
<CausalFlowDiagram chain={chain} loading={loading} />
|
||||
<CausalFlow chain={chain} loading={chainLoading} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: 14 }}>
|
||||
Select a decision to inspect its causal chain.
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", position: "relative", zIndex: 1 }}>
|
||||
<div className="ws-empty">
|
||||
<div className="ws-empty-icon"><Scale size={32} /></div>
|
||||
<div className="ws-empty-title">No decision selected</div>
|
||||
<div className="ws-empty-body">Select a decision from the list to inspect its causal chain and metadata.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const filterInputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "7px 10px 7px 30px",
|
||||
background: "rgba(0,0,0,0.25)",
|
||||
border: "1px solid rgba(88,166,255,0.16)",
|
||||
borderRadius: 8,
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
@@ -1,105 +1,121 @@
|
||||
/**
|
||||
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { GitMerge, ArrowRight, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
interface FieldRow { label: string; primary: string; duplicate: string; differs: boolean }
|
||||
|
||||
const MOCK_FIELDS: FieldRow[] = [
|
||||
{ label: "Name", primary: "Sample Company Inc.", duplicate: "Sample Company", differs: true },
|
||||
{ label: "Founded", primary: "2004-05-12", duplicate: "2004-05-12", differs: false },
|
||||
{ label: "Type", primary: "Organization", duplicate: "Organisation", differs: true },
|
||||
{ label: "Country", primary: "US", duplicate: "US", differs: false },
|
||||
];
|
||||
|
||||
export function DiffMergeWorkspace() {
|
||||
const [primaryId, setPrimaryId] = useState("n-primary-1");
|
||||
const [primaryId, setPrimaryId] = useState("n-primary-1");
|
||||
const [duplicateId, setDuplicateId] = useState("n-dup-2");
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const handleMerge = async () => {
|
||||
async function handleMerge() {
|
||||
setStatus("loading");
|
||||
setMsg("");
|
||||
try {
|
||||
const res = await fetch("/api/enrich/merge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] })
|
||||
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.merged_into) {
|
||||
setMsg(`Merge success: redirected ${data.edges_updated} edges to ${data.merged_into}`);
|
||||
logEvent("merge", `Merged ${duplicateId} → ${data.merged_into} · ${data.edges_updated} edges redirected`, {
|
||||
primary: data.merged_into,
|
||||
duplicate: duplicateId,
|
||||
edgesUpdated: data.edges_updated,
|
||||
setStatus("success");
|
||||
setMsg(`Merged → ${data.merged_into} · ${data.edges_updated ?? 0} edges redirected`);
|
||||
logEvent("merge", `Merged ${duplicateId} → ${data.merged_into} · ${data.edges_updated ?? 0} edges redirected`, {
|
||||
primary: data.merged_into, duplicate: duplicateId, edgesUpdated: data.edges_updated,
|
||||
});
|
||||
} else {
|
||||
setMsg("Merge failed...");
|
||||
throw new Error(data.detail || "Unexpected response");
|
||||
}
|
||||
} catch (err) {
|
||||
setMsg("Error calling merge endpoint.");
|
||||
} catch (e: unknown) {
|
||||
setStatus("error");
|
||||
setMsg(e instanceof Error ? e.message : "Merge failed");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 32, gap: 24, boxSizing: "border-box" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 8px 0", color: "#fff" }}>Entity Diff & Merge</h1>
|
||||
<p style={{ margin: 0, color: "#8b949e" }}>Compare suspected duplicate entities and reconcile them.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 24, flex: 1 }}>
|
||||
{/* Primary View */}
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, padding: 24 }}>
|
||||
<h3 style={{ color: "#58a6ff", margin: "0 0 16px 0", borderBottom: "1px solid rgba(88,166,255,0.2)", paddingBottom: 8 }}>Primary Entity</h3>
|
||||
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 13 }}>Primary Node ID</label>
|
||||
<input
|
||||
value={primaryId} onChange={e => setPrimaryId(e.target.value)}
|
||||
style={{ width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", color: "#fff", padding: "8px 12px", borderRadius: 6, marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<div style={{ background: "rgba(0,0,0,0.2)", padding: 16, borderRadius: 6 }}>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginBottom: 4 }}>Name</div>
|
||||
<div style={{ color: "#fff", fontSize: 14 }}>Sample Company Inc.</div>
|
||||
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 16, marginBottom: 4 }}>Founded</div>
|
||||
<div style={{ color: "#fff", fontSize: 14 }}>2004-05-12</div>
|
||||
<div className="ws-page ws-scroll">
|
||||
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 22, maxWidth: 1000, margin: "0 auto", width: "100%" }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<div style={{ width: 42, height: 42, borderRadius: 13, background: "var(--ws-purple-soft)", border: "1px solid rgba(192,132,252,0.3)", display: "grid", placeItems: "center", color: "var(--ws-purple)", flexShrink: 0 }}>
|
||||
<GitMerge size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="ws-title" style={{ fontSize: 18 }}>Entity Diff & Merge</h2>
|
||||
<div className="ws-body" style={{ marginTop: 2 }}>Compare suspected duplicates side-by-side and reconcile them into a single canonical entity.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate View */}
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, padding: 24 }}>
|
||||
<h3 style={{ color: "#ff7b72", margin: "0 0 16px 0", borderBottom: "1px solid rgba(255,123,114,0.2)", paddingBottom: 8 }}>Duplicate Entity</h3>
|
||||
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 13 }}>Duplicate Node ID</label>
|
||||
<input
|
||||
value={duplicateId} onChange={e => setDuplicateId(e.target.value)}
|
||||
style={{ width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", color: "#fff", padding: "8px 12px", borderRadius: 6, marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<div style={{ background: "rgba(0,0,0,0.2)", padding: 16, borderRadius: 6 }}>
|
||||
<div style={{ color: "#d2a8ff", fontSize: 12, marginBottom: 4 }}>Name</div>
|
||||
{/* Amber highlight for differing values */}
|
||||
<div style={{ color: "#d29922", fontSize: 14, fontWeight: "bold" }}>Sample Company</div>
|
||||
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 16, marginBottom: 4 }}>Founded</div>
|
||||
<div style={{ color: "#fff", fontSize: 14 }}>2004-05-12</div>
|
||||
{/* ID inputs */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr auto 1fr", gap: 12, alignItems: "end" }}>
|
||||
<div>
|
||||
<label className="ws-label">Primary Node ID (keep)</label>
|
||||
<input className="ws-input" value={primaryId} onChange={(e) => { setPrimaryId(e.target.value); setStatus("idle"); }} placeholder="e.g. n-primary-1" />
|
||||
</div>
|
||||
<div style={{ paddingBottom: 2, color: "var(--ws-text-dim)" }}>
|
||||
<ArrowRight size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="ws-label">Duplicate Node ID (remove)</label>
|
||||
<input className="ws-input" value={duplicateId} onChange={(e) => { setDuplicateId(e.target.value); setStatus("idle"); }} placeholder="e.g. n-dup-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ color: "#58a6ff" }}>{msg}</div>
|
||||
<button
|
||||
onClick={handleMerge}
|
||||
style={{ background: "#238636", color: "#fff", border: "none", padding: "10px 24px", borderRadius: 6, fontWeight: 600, cursor: "pointer", fontSize: 16 }}
|
||||
>
|
||||
Confirm Merge
|
||||
</button>
|
||||
</div>
|
||||
{/* Diff table */}
|
||||
<div className="ws-card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div style={{ padding: "6px 16px", background: "rgba(242,182,109,0.06)", borderBottom: "1px solid rgba(242,182,109,0.15)", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: "var(--ws-amber)", letterSpacing: "0.08em", textTransform: "uppercase" }}>Sample preview</span>
|
||||
<span style={{ fontSize: 11, color: "var(--ws-text-dim)" }}>— field comparison will load from the graph once the backend is connected</span>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr 1fr", background: "rgba(0,0,0,0.28)", borderBottom: "1px solid var(--ws-border)" }}>
|
||||
<div style={{ padding: "10px 16px", fontSize: 11, fontWeight: 700, color: "var(--ws-text-dim)", letterSpacing: "0.08em", textTransform: "uppercase" }}>Field</div>
|
||||
<div style={{ padding: "10px 16px", fontSize: 11, fontWeight: 700, color: "var(--ws-accent)", letterSpacing: "0.08em", textTransform: "uppercase", borderLeft: "1px solid var(--ws-border)" }}>Primary (keep)</div>
|
||||
<div style={{ padding: "10px 16px", fontSize: 11, fontWeight: 700, color: "#fca5a5", letterSpacing: "0.08em", textTransform: "uppercase", borderLeft: "1px solid var(--ws-border)" }}>Duplicate (remove)</div>
|
||||
</div>
|
||||
{MOCK_FIELDS.map((row) => (
|
||||
<div key={row.label} style={{ display: "grid", gridTemplateColumns: "140px 1fr 1fr", borderBottom: "1px solid rgba(74,163,255,0.06)", background: row.differs ? "rgba(242,182,109,0.03)" : "transparent" }}>
|
||||
<div style={{ padding: "12px 16px", fontSize: 12, fontWeight: 600, color: "var(--ws-text-dim)" }}>{row.label}</div>
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "var(--ws-text)", borderLeft: "1px solid var(--ws-border)" }}>{row.primary}</div>
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: row.differs ? "#fbbf24" : "var(--ws-text)", fontWeight: row.differs ? 700 : 400, borderLeft: "1px solid var(--ws-border)" }}>
|
||||
{row.duplicate}
|
||||
{row.differs && <span className="ws-pill ws-pill--amber" style={{ marginLeft: 8, fontSize: 9 }}>diff</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status & action */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{status === "success" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, color: "#6ee7b7", fontSize: 13 }}>
|
||||
<CheckCircle2 size={15} />{msg}
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, color: "#fca5a5", fontSize: 13 }}>
|
||||
<AlertCircle size={15} />{msg}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="ws-btn ws-btn--primary"
|
||||
onClick={handleMerge}
|
||||
disabled={status === "loading" || !primaryId || !duplicateId}
|
||||
style={{ marginLeft: "auto" }}
|
||||
>
|
||||
{status === "loading" ? <><Loader2 size={14} className="ws-spin" />Merging…</> : <><GitMerge size={14} />Confirm Merge</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,270 +1,196 @@
|
||||
/**
|
||||
* src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { UploadCloud, Download, FileJson, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
.dropzone {
|
||||
border: 2px dashed rgba(88,166,255,0.4);
|
||||
border-radius: 12px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropzone:hover, .dropzone.active {
|
||||
border-color: #58a6ff;
|
||||
background: rgba(88,166,255,0.05);
|
||||
}
|
||||
.btn-primary {
|
||||
background: #238636;
|
||||
color: #fff;
|
||||
border: 1px solid rgba(240,246,252,0.1);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2ea043;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.toast {
|
||||
animation: slideUp 0.3s ease-out forwards;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`;
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
}
|
||||
interface Toast { id: number; type: "success" | "error"; text: string }
|
||||
|
||||
export function ImportExportWorkspace() {
|
||||
// Import State
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// Export State
|
||||
const [exportFormat, setExportFormat] = useState<"json" | "csv">("json");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
// Toasts
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const showToast = (type: "success" | "error", text: string) => {
|
||||
function showToast(type: Toast["type"], text: string) {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, type, text }]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 5000);
|
||||
};
|
||||
setToasts((p) => [...p, { id, type, text }]);
|
||||
setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 5000);
|
||||
}
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setFile(acceptedFiles[0]);
|
||||
}
|
||||
const onDrop = useCallback((accepted: File[]) => {
|
||||
if (accepted.length > 0) setFile(accepted[0]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/json': ['.json'],
|
||||
'text/csv': ['.csv']
|
||||
},
|
||||
maxFiles: 1
|
||||
accept: { "application/json": [".json"], "text/csv": [".csv"] },
|
||||
maxFiles: 1,
|
||||
});
|
||||
|
||||
const handleImport = async () => {
|
||||
async function handleImport() {
|
||||
if (!file) return;
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/api/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Import failed");
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/import", { method: "POST", body: fd });
|
||||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Import failed"); }
|
||||
const data = await res.json();
|
||||
showToast("success", `Imported ${data.nodes_imported} nodes and ${data.edges_imported} edges!`);
|
||||
logEvent("import", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges from ${file.name}`, {
|
||||
file: file.name,
|
||||
nodesImported: data.nodes_imported,
|
||||
edgesImported: data.edges_imported,
|
||||
});
|
||||
showToast("success", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges`);
|
||||
logEvent("import", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges from ${file.name}`, { file: file.name });
|
||||
setFile(null);
|
||||
} catch (err: any) {
|
||||
showToast("error", err.message || "An error occurred during import");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
showToast("error", e instanceof Error ? e.message : "Import failed");
|
||||
} finally { setIsUploading(false); }
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
async function handleExport() {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const res = await fetch("/api/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ format: exportFormat })
|
||||
body: JSON.stringify({ format: exportFormat }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Export failed");
|
||||
}
|
||||
|
||||
// Handle file download
|
||||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Export failed"); }
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
// Provide a default extension based on format
|
||||
a.download = `semantica_export.${exportFormat}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showToast("success", "Export complete! Your download should begin shortly.");
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("success", `Export ready — semantica_export.${exportFormat}`);
|
||||
logEvent("export", `Exported graph as ${exportFormat.toUpperCase()}`, { format: exportFormat });
|
||||
} catch (err: any) {
|
||||
showToast("error", err.message || "An error occurred during export");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
showToast("error", e instanceof Error ? e.message : "Export failed");
|
||||
} finally { setIsExporting(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 32, gap: 24, boxSizing: "border-box", overflowY: "auto" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 8px 0", color: "#fff", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<UploadCloud size={28} color="#58a6ff" /> Data Import / Export
|
||||
</h1>
|
||||
<p style={{ margin: 0, color: "#8b949e" }}>Ingest new graph datasets or extract the current knowledge base.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
|
||||
|
||||
{/* IMPORT PANEL */}
|
||||
<div className="glass-panel" style={{ flex: "1 1 400px", borderRadius: 12, padding: 32, display: "flex", flexDirection: "column" }}>
|
||||
<h2 style={{ margin: "0 0 24px 0", color: "#58a6ff", fontSize: 20, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<UploadCloud size={20} /> Import Entities & Relations
|
||||
</h2>
|
||||
|
||||
<div {...getRootProps()} className={`dropzone ${isDragActive ? 'active' : ''}`} style={{ padding: 48, textAlign: "center", marginBottom: 24, flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
||||
<input {...getInputProps()} />
|
||||
{file ? (
|
||||
<>
|
||||
{file.name.endsWith(".json") ? <FileJson size={48} color="#3fb950" style={{ marginBottom: 16 }} /> : <FileText size={48} color="#3fb950" style={{ marginBottom: 16 }} />}
|
||||
<p style={{ color: "#fff", fontWeight: 600, margin: "0 0 8px 0" }}>{file.name}</p>
|
||||
<p style={{ color: "#8b949e", fontSize: 13, margin: 0 }}>{(file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud size={48} color="#58a6ff" style={{ marginBottom: 16, opacity: 0.8 }} />
|
||||
<p style={{ color: "#c9d1d9", fontSize: 16, fontWeight: 500, margin: "0 0 8px 0" }}>Drag & drop your file here</p>
|
||||
<p style={{ color: "#8b949e", fontSize: 13, margin: 0 }}>Supports .json and .csv formats</p>
|
||||
</>
|
||||
)}
|
||||
<div className="ws-page ws-scroll">
|
||||
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 980, margin: "0 auto", width: "100%" }}>
|
||||
{/* Page header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<div style={{ width: 42, height: 42, borderRadius: 13, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
|
||||
<UploadCloud size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="ws-title" style={{ fontSize: 18 }}>Import & Export</h2>
|
||||
<div className="ws-body" style={{ marginTop: 2 }}>Ingest new graph datasets or extract the current knowledge base.</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleImport}
|
||||
disabled={!file || isUploading}
|
||||
style={{ width: "100%", padding: "12px 24px", borderRadius: 8, fontSize: 16, fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, cursor: (!file || isUploading) ? "not-allowed" : "pointer", border: "none" }}
|
||||
>
|
||||
{isUploading ? <Loader2 size={20} className="animate-spin" /> : <UploadCloud size={20} />}
|
||||
{isUploading ? "Uploading..." : "Upload to Graph"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* EXPORT PANEL */}
|
||||
<div className="glass-panel" style={{ flex: "1 1 400px", borderRadius: 12, padding: 32, display: "flex", flexDirection: "column" }}>
|
||||
<h2 style={{ margin: "0 0 24px 0", color: "#d2a8ff", fontSize: 20, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Download size={20} /> Export Graph Snapshot
|
||||
</h2>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 14, fontWeight: 500 }}>Export Format</label>
|
||||
<div style={{ position: "relative", marginBottom: 32 }}>
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={e => setExportFormat(e.target.value as "json" | "csv")}
|
||||
style={{ width: "100%", appearance: "none", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(88,166,255,0.3)", color: "#fff", padding: "12px 16px", borderRadius: 8, fontSize: 15, cursor: "pointer", outline: "none" }}
|
||||
>
|
||||
<option value="json" style={{ background: "#0d1117" }}>JSON (Full Graph Dictionary)</option>
|
||||
<option value="csv" style={{ background: "#0d1117" }}>CSV (Tabular Dump)</option>
|
||||
</select>
|
||||
<div style={{ position: "absolute", right: 16, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}>
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.41 0.589966L6 5.16997L10.59 0.589966L12 1.99997L6 7.99997L0 1.99997L1.41 0.589966Z" fill="#8b949e"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 18 }}>
|
||||
{/* ── Import card ── */}
|
||||
<div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<UploadCloud size={16} color="var(--ws-accent)" />
|
||||
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Import Entities & Relations</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: "rgba(0,0,0,0.2)", padding: 20, borderRadius: 8, border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<h4 style={{ color: "#c9d1d9", margin: "0 0 8px 0", fontSize: 14 }}>Export Details</h4>
|
||||
<p style={{ color: "#8b949e", fontSize: 13, margin: 0, lineHeight: 1.5 }}>
|
||||
{exportFormat === "json"
|
||||
? "Exports the entire graph including all node properties, edge weights, and complete entity metadata into a standardized JSON payload."
|
||||
: "Exports a flattened CSV tabular representation of all nodes and edges. Complex nested properties will be omitted or stringified."}
|
||||
</p>
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
border: `2px dashed ${isDragActive ? "var(--ws-accent)" : "var(--ws-border)"}`,
|
||||
borderRadius: "var(--ws-radius)",
|
||||
background: isDragActive ? "var(--ws-accent-soft)" : "rgba(0,0,0,0.18)",
|
||||
padding: 32,
|
||||
textAlign: "center",
|
||||
cursor: "pointer",
|
||||
transition: "all 200ms ease",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{file ? (
|
||||
<>
|
||||
{file.name.endsWith(".json") ? <FileJson size={40} color="#4cc38a" /> : <FileText size={40} color="#4cc38a" />}
|
||||
<div style={{ color: "var(--ws-text)", fontWeight: 700 }}>{file.name}</div>
|
||||
<div className="ws-body" style={{ fontSize: 11 }}>{(file.size / 1024).toFixed(1)} KB — click to replace</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud size={36} color="var(--ws-accent)" style={{ opacity: 0.7 }} />
|
||||
<div style={{ color: "var(--ws-text)", fontWeight: 600 }}>Drag & drop or click to browse</div>
|
||||
<div className="ws-pill ws-pill--mono">.json</div>
|
||||
<span style={{ color: "var(--ws-text-dim)", fontSize: 11 }}>or</span>
|
||||
<div className="ws-pill ws-pill--mono">.csv</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="ws-btn ws-btn--primary"
|
||||
onClick={handleImport}
|
||||
disabled={!file || isUploading}
|
||||
style={{ width: "100%", justifyContent: "center" }}
|
||||
>
|
||||
{isUploading ? <><Loader2 size={15} className="ws-spin" />Uploading…</> : <><UploadCloud size={15} />Upload to Graph</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={{ width: "100%", background: "#1f6feb", color: "#fff", padding: "12px 24px", borderRadius: 8, fontSize: 16, fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, cursor: isExporting ? "not-allowed" : "pointer", border: "1px solid rgba(240,246,252,0.1)", transition: "background 0.2s" }}
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
onMouseOver={e => { if(!isExporting) (e.currentTarget.style.background = "#388bfd") }}
|
||||
onMouseOut={e => { if(!isExporting) (e.currentTarget.style.background = "#1f6feb") }}
|
||||
>
|
||||
{isExporting ? <Loader2 size={20} className="animate-spin" /> : <Download size={20} />}
|
||||
{isExporting ? "Preparing Extract..." : "Download Graph Extract"}
|
||||
</button>
|
||||
{/* ── Export card ── */}
|
||||
<div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Download size={16} color="var(--ws-purple)" />
|
||||
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Export Graph Snapshot</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ws-label">Format</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["json", "csv"] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
className={`ws-btn ${exportFormat === fmt ? "ws-btn--primary" : "ws-btn--ghost"}`}
|
||||
style={{ flex: 1, justifyContent: "center", textTransform: "uppercase", fontSize: 12 }}
|
||||
onClick={() => setExportFormat(fmt)}
|
||||
>
|
||||
{fmt === "json" ? <FileJson size={14} /> : <FileText size={14} />}
|
||||
{fmt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: "14px 16px", borderRadius: "var(--ws-radius-sm)", background: "rgba(0,0,0,0.22)", border: "1px solid var(--ws-border)" }}>
|
||||
<div style={{ color: "var(--ws-text-muted)", fontWeight: 700, fontSize: 12, marginBottom: 6 }}>What's included</div>
|
||||
<div className="ws-body" style={{ fontSize: 12 }}>
|
||||
{exportFormat === "json"
|
||||
? "Full graph snapshot: all node properties, edge weights, entity metadata and semantic groups in a standardized JSON payload."
|
||||
: "Flattened CSV: nodes and edges as rows. Complex nested properties are stringified. Best for spreadsheet analysis."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="ws-btn"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
style={{ width: "100%", justifyContent: "center", background: "var(--ws-purple-soft)", borderColor: "rgba(192,132,252,0.3)", color: "#d8b4fe" }}
|
||||
>
|
||||
{isExporting ? <><Loader2 size={15} className="ws-spin" />Preparing…</> : <><Download size={15} />Download Export</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<div style={{ position: "fixed", bottom: 32, right: 32, display: "flex", flexDirection: "column", gap: 12, zIndex: 1000 }}>
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className="toast" style={{
|
||||
display: "flex", alignItems: "center", gap: 12, padding: "16px 20px", borderRadius: 8,
|
||||
background: toast.type === 'success' ? '#1b4a24' : '#571822',
|
||||
border: `1px solid ${toast.type === 'success' ? 'rgba(63, 185, 80, 0.4)' : 'rgba(248, 81, 73, 0.4)'}`,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.5)"
|
||||
}}>
|
||||
{toast.type === 'success' ? <CheckCircle2 color="#3fb950" size={20}/> : <AlertCircle color="#f85149" size={20}/>}
|
||||
<span style={{ color: "#fff", fontSize: 14, fontWeight: 500 }}>{toast.text}</span>
|
||||
{/* Toasts */}
|
||||
<div style={{ position: "fixed", bottom: 28, right: 28, display: "flex", flexDirection: "column", gap: 10, zIndex: 1000 }}>
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className="ws-animate-in" style={{ display: "flex", alignItems: "center", gap: 10, padding: "13px 18px", borderRadius: "var(--ws-radius-sm)", background: t.type === "success" ? "rgba(16,36,22,0.96)" : "rgba(40,10,10,0.96)", border: `1px solid ${t.type === "success" ? "rgba(76,195,138,0.4)" : "rgba(255,123,114,0.4)"}`, boxShadow: "0 8px 24px rgba(0,0,0,0.5)", backdropFilter: "blur(12px)" }}>
|
||||
{t.type === "success" ? <CheckCircle2 size={16} color="#4cc38a" /> : <AlertCircle size={16} color="#ff7b72" />}
|
||||
<span style={{ color: "var(--ws-text)", fontSize: 13, fontWeight: 500 }}>{t.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,30 @@
|
||||
* src/workspaces/LineageWorkspace/LineageDiagram.tsx
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { ReactFlow, Background, Controls } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
const THEME_CSS = `
|
||||
.react-flow { background: #0d1117; }
|
||||
.react-flow { background: var(--ws-bg, #060d1a); }
|
||||
.react-flow__node-group {
|
||||
background: rgba(88,166,255,0.05);
|
||||
border: 1px dashed rgba(88,166,255,0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(74,163,255,0.04);
|
||||
border: 1px dashed rgba(74,163,255,0.18);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.react-flow__node-default {
|
||||
background: #161b22;
|
||||
color: #c9d1d9;
|
||||
border: 1px solid rgba(88,166,255,0.3);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: rgba(6,13,26,0.92);
|
||||
color: var(--ws-text, #ddeeff);
|
||||
border: 1px solid rgba(74,163,255,0.22);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
.react-flow__controls { background: rgba(6,13,26,0.9); border: 1px solid rgba(74,163,255,0.18); border-radius: 10px; }
|
||||
.react-flow__controls-button { background: transparent; border-color: rgba(74,163,255,0.15); color: var(--ws-text-muted, #5a7a9a); }
|
||||
.react-flow__controls-button:hover { background: rgba(74,163,255,0.1); color: var(--ws-text, #ddeeff); }
|
||||
`;
|
||||
|
||||
export function LineageDiagram() {
|
||||
@@ -111,95 +116,43 @@ export function LineageDiagram() {
|
||||
}, [activeId]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", position: "relative", background: "#0d1117" }}>
|
||||
<div style={{ width: "100%", height: "100%", position: "relative", background: "var(--ws-bg, #060d1a)" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Top Bar Navigation */}
|
||||
<div style={{ position: "absolute", top: 16, left: 16, zIndex: 10, display: "flex", gap: "12px", alignItems: "center" }}>
|
||||
<div style={{ background: "rgba(13,17,23,0.8)", padding: "4px 8px", borderRadius: 4, color: "#fff", fontWeight: 600, border: "1px solid rgba(255,255,255,0.1)", pointerEvents: "none" }}>
|
||||
PROV-O Lineage
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Node ID..."
|
||||
value={searchId}
|
||||
onChange={(e) => setSearchId(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setActiveId(searchId);
|
||||
}}
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
border: "1px solid rgba(88,166,255,0.3)",
|
||||
color: "#c9d1d9",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
width: "200px"
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setActiveId(searchId)}
|
||||
style={{
|
||||
background: "#1f6feb",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
padding: "5px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void downloadReport("json")}
|
||||
disabled={!activeId}
|
||||
style={{
|
||||
background: "rgba(31, 111, 235, 0.18)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(88,166,255,0.3)",
|
||||
padding: "5px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: activeId ? "pointer" : "not-allowed",
|
||||
fontWeight: 500,
|
||||
opacity: activeId ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void downloadReport("markdown")}
|
||||
disabled={!activeId}
|
||||
style={{
|
||||
background: "rgba(31, 111, 235, 0.18)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(88,166,255,0.3)",
|
||||
padding: "5px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: activeId ? "pointer" : "not-allowed",
|
||||
fontWeight: 500,
|
||||
opacity: activeId ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ position: "absolute", top: 14, left: 14, right: 14, zIndex: 10, display: "flex", gap: 8, alignItems: "center", background: "rgba(4,10,18,0.88)", backdropFilter: "blur(14px)", padding: "8px 12px", borderRadius: 12, border: "1px solid rgba(74,163,255,0.16)", boxShadow: "0 4px 20px rgba(0,0,0,0.4)" }}>
|
||||
<span className="ws-eyebrow" style={{ color: "var(--ws-accent, #4aa3ff)", marginRight: 4 }}>PROV-O Lineage</span>
|
||||
<input
|
||||
className="ws-input"
|
||||
type="text"
|
||||
placeholder="Enter Node ID…"
|
||||
value={searchId}
|
||||
onChange={(e) => setSearchId(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") setActiveId(searchId); }}
|
||||
style={{ width: 200, padding: "6px 10px", fontSize: 12 }}
|
||||
/>
|
||||
<button className="ws-btn ws-btn--primary" style={{ padding: "6px 12px" }} onClick={() => setActiveId(searchId)}>
|
||||
Trace
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="ws-btn ws-btn--ghost" style={{ padding: "6px 12px", fontSize: 11 }} disabled={!activeId} onClick={() => void downloadReport("json")}>
|
||||
Export JSON
|
||||
</button>
|
||||
<button className="ws-btn ws-btn--ghost" style={{ padding: "6px 12px", fontSize: 11 }} disabled={!activeId} onClick={() => void downloadReport("markdown")}>
|
||||
Export MD
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeId ? (
|
||||
<ReactFlow nodes={nodes} edges={edges} fitView>
|
||||
<Background color="#30363d" gap={20} />
|
||||
<Background color="rgba(74,163,255,0.08)" gap={24} />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<div style={{ display: "flex", height: "100%", width: "100%", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: "14px" }}>
|
||||
Enter a Node ID to view its W3C PROV-O lineage.
|
||||
<div className="ws-empty" style={{ height: "100%", paddingTop: 72 }}>
|
||||
<div className="ws-empty-icon"><Link2 size={36} color="var(--ws-accent)" /></div>
|
||||
<div className="ws-empty-title">PROV-O Lineage Viewer</div>
|
||||
<div className="ws-empty-body">Enter a Node ID in the toolbar above and click Trace to view its W3C PROV-O lineage diagram.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -127,58 +127,59 @@ export function KGOverviewTab() {
|
||||
const totalNodes = stats?.node_count ?? 0;
|
||||
const totalEdges = stats?.edge_count ?? 0;
|
||||
|
||||
const statCards = [
|
||||
{ label: "Nodes", value: totalNodes.toLocaleString(), color: "var(--ws-accent)", sub: `${nodeTypeEntries.length} types` },
|
||||
{ label: "Edges", value: totalEdges.toLocaleString(), color: "var(--ws-green)", sub: `${edgeTypeEntries.length} rel. types` },
|
||||
{ label: "Density", value: totalNodes > 1 ? ((totalEdges / (totalNodes * (totalNodes - 1))) * 100).toFixed(3) + "%" : "—", color: "var(--ws-purple)", sub: "graph density" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
<div className="ws-page">
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 22px", borderBottom: "1px solid var(--ws-border)", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Network size={18} color="#4aa3ff" />
|
||||
<div style={{ width: 32, height: 32, borderRadius: 9, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)" }}>
|
||||
<Network size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>KG Overview</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>Quick view of the Knowledge Graph structure and health</div>
|
||||
<div style={{ color: "var(--ws-text)", fontSize: 15, fontWeight: 700, lineHeight: 1 }}>KG Overview</div>
|
||||
<div className="ws-body" style={{ fontSize: 11, marginTop: 2 }}>Node/edge counts, type distributions, and top connected nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => void fetchOverview()} disabled={loading} style={refreshBtnStyle}>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
<span>Refresh</span>
|
||||
<button className="ws-btn ws-btn--ghost" onClick={() => void fetchOverview()} disabled={loading} style={{ padding: "6px 12px" }}>
|
||||
{loading ? <Loader2 size={13} className="ws-spin" /> : <RefreshCw size={13} />}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div style={{ margin: "16px 24px", padding: "10px 14px", borderRadius: 10, background: "rgba(255,123,114,0.08)", border: "1px solid rgba(255,123,114,0.2)", color: "#ff7b72", fontSize: 13 }}>
|
||||
{error && (
|
||||
<div style={{ margin: "12px 22px", padding: "10px 14px", borderRadius: "var(--ws-radius-sm)", background: "var(--ws-red-soft)", border: "1px solid rgba(255,123,114,0.28)", color: "#fca5a5", fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<div style={scrollBodyStyle}>
|
||||
{/* Stats chips */}
|
||||
<div style={statsRowStyle}>
|
||||
{[
|
||||
{ label: "Nodes", value: totalNodes.toLocaleString(), color: "#4aa3ff", sub: `${nodeTypeEntries.length} types` },
|
||||
{ label: "Edges", value: totalEdges.toLocaleString(), color: "#4cc38a", sub: `${edgeTypeEntries.length} relationship types` },
|
||||
{ label: "Density", value: totalNodes > 1 ? ((totalEdges / (totalNodes * (totalNodes - 1))) * 100).toFixed(3) + "%" : "—", color: "#d2a8ff", sub: "graph density" },
|
||||
].map(({ label, value, color, sub }) => (
|
||||
<div key={label} style={statCardStyle}>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ color, fontSize: 28, fontWeight: 800, letterSpacing: "-0.04em", lineHeight: 1 }}>{loading ? "—" : value}</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, marginTop: 4 }}>{sub}</div>
|
||||
<div className="ws-scroll" style={{ flex: 1, padding: "18px 22px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* Stat cards */}
|
||||
<div className="ws-stat-grid ws-stat-grid--3">
|
||||
{statCards.map(({ label, value, color, sub }) => (
|
||||
<div key={label} className="ws-stat-card">
|
||||
<div className="ws-eyebrow" style={{ marginBottom: 6 }}>{label}</div>
|
||||
<div className="ws-stat-value" style={{ color }}>{loading ? "—" : value}</div>
|
||||
<div className="ws-stat-label">{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Type breakdowns */}
|
||||
<div style={sectionRowStyle}>
|
||||
{/* Node types */}
|
||||
<div style={breakdownCardStyle}>
|
||||
<div style={sectionTitleStyle}>Node Type Breakdown</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
|
||||
<div className="ws-card" style={{ padding: "16px 18px", gap: 8, display: "flex", flexDirection: "column" }}>
|
||||
<div className="ws-eyebrow" style={{ marginBottom: 4 }}>Node Type Breakdown</div>
|
||||
{loading ? (
|
||||
<div style={skeletonWrapStyle}>
|
||||
{[80, 65, 45, 35, 25].map((w, i) => (
|
||||
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
|
||||
))}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[80, 65, 45, 35, 25].map((w, i) => <div key={i} className="ws-skeleton" style={{ height: 10, width: `${w}%` }} />)}
|
||||
</div>
|
||||
) : nodeTypeEntries.length === 0 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 12 }}>No data — load the graph first.</div>
|
||||
<div className="ws-body" style={{ fontSize: 12 }}>No data — load the graph first.</div>
|
||||
) : (
|
||||
nodeTypeEntries.slice(0, 8).map(([type, count], i) => (
|
||||
<TypeBar key={type} label={type} count={count} total={totalNodes || 1} color={NODE_COLORS[i % NODE_COLORS.length]} />
|
||||
@@ -186,17 +187,14 @@ export function KGOverviewTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edge types */}
|
||||
<div style={breakdownCardStyle}>
|
||||
<div style={sectionTitleStyle}>Edge Type Breakdown</div>
|
||||
<div className="ws-card" style={{ padding: "16px 18px", gap: 8, display: "flex", flexDirection: "column" }}>
|
||||
<div className="ws-eyebrow" style={{ marginBottom: 4 }}>Edge Type Breakdown</div>
|
||||
{loading ? (
|
||||
<div style={skeletonWrapStyle}>
|
||||
{[70, 55, 48, 30, 20].map((w, i) => (
|
||||
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
|
||||
))}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[70, 55, 48, 30, 20].map((w, i) => <div key={i} className="ws-skeleton" style={{ height: 10, width: `${w}%` }} />)}
|
||||
</div>
|
||||
) : edgeTypeEntries.length === 0 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 12 }}>Edge type breakdown requires the stats endpoint to return edge_types.</div>
|
||||
<div className="ws-body" style={{ fontSize: 12 }}>Edge type breakdown requires the stats endpoint to return edge_types.</div>
|
||||
) : (
|
||||
edgeTypeEntries.slice(0, 8).map(([type, count], i) => (
|
||||
<TypeBar key={type} label={type} count={count} total={totalEdges || 1} color={EDGE_COLORS[i % EDGE_COLORS.length]} />
|
||||
@@ -206,134 +204,24 @@ export function KGOverviewTab() {
|
||||
</div>
|
||||
|
||||
{/* Top connected nodes */}
|
||||
{topNodes.length > 0 ? (
|
||||
<div style={breakdownCardStyle}>
|
||||
<div style={sectionTitleStyle}>Top Connected Nodes (by degree)</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 8, marginTop: 2 }}>
|
||||
{topNodes.length > 0 && (
|
||||
<div className="ws-card" style={{ padding: "16px 18px" }}>
|
||||
<div className="ws-eyebrow" style={{ marginBottom: 12 }}>Top Connected Nodes (by degree)</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 8 }}>
|
||||
{topNodes.map(({ node, neighborCount }, rank) => (
|
||||
<div key={node.id} style={topNodeRowStyle}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, fontWeight: 700, minWidth: 20 }}>#{rank + 1}</div>
|
||||
<div key={node.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 12px", borderRadius: "var(--ws-radius-sm)", background: "rgba(0,0,0,0.18)", border: "1px solid var(--ws-border)" }}>
|
||||
<div style={{ color: "var(--ws-text-dim)", fontSize: 11, fontWeight: 700, minWidth: 22 }}>#{rank + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{node.content || node.id}
|
||||
</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 11 }}>{node.type}</div>
|
||||
</div>
|
||||
<div style={{ color: "#4aa3ff", fontSize: 12, fontWeight: 700, flexShrink: 0 }}>
|
||||
{neighborCount} conn.
|
||||
<div style={{ color: "var(--ws-text)", fontSize: 12, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{node.content || node.id}</div>
|
||||
<div className="ws-eyebrow" style={{ marginTop: 2, fontSize: 9 }}>{node.type}</div>
|
||||
</div>
|
||||
<span className="ws-pill ws-pill--accent" style={{ fontSize: 10 }}>{neighborCount}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0d1117",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "20px 24px 16px",
|
||||
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const refreshBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.16)",
|
||||
background: "rgba(74,163,255,0.08)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const scrollBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "20px 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
};
|
||||
|
||||
const statsRowStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const statCardStyle: React.CSSProperties = {
|
||||
padding: "18px 20px",
|
||||
borderRadius: 16,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.8), rgba(22,27,34,0.5))",
|
||||
border: "1px solid rgba(127,208,255,0.1)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)",
|
||||
};
|
||||
|
||||
const sectionRowStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const breakdownCardStyle: React.CSSProperties = {
|
||||
padding: "16px 18px",
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.7), rgba(22,27,34,0.4))",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const topNodeRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 10,
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const skeletonWrapStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const skeletonBarStyle: React.CSSProperties = {
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
animation: "skeleton-pulse 1.4s ease-in-out infinite",
|
||||
};
|
||||
|
||||
@@ -49,18 +49,20 @@ export function AlignmentsTab() {
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setError("");
|
||||
try {
|
||||
const [registryData, alignmentData] = await Promise.all([
|
||||
loadOntologyRegistry(),
|
||||
loadAlignments(),
|
||||
]);
|
||||
setRegistry(registryData);
|
||||
setAlignments(alignmentData);
|
||||
setSourceOntology((current) => current || registryData[0]?.uri || "");
|
||||
setTargetOntology((current) => current || registryData[1]?.uri || registryData[0]?.uri || "");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not load ontology alignments.");
|
||||
const [registryResult, alignmentResult] = await Promise.allSettled([
|
||||
loadOntologyRegistry(),
|
||||
loadAlignments(),
|
||||
]);
|
||||
|
||||
if (registryResult.status === "fulfilled") {
|
||||
setRegistry(registryResult.value);
|
||||
setSourceOntology((current) => current || registryResult.value[0]?.uri || "");
|
||||
setTargetOntology((current) => current || registryResult.value[1]?.uri || registryResult.value[0]?.uri || "");
|
||||
}
|
||||
if (alignmentResult.status === "fulfilled") {
|
||||
setAlignments(alignmentResult.value);
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -284,7 +286,7 @@ export function AlignmentsTab() {
|
||||
</div>
|
||||
</div>
|
||||
<button style={primaryButtonStyle} disabled={busy} onClick={handleSave}>
|
||||
{busy ? <Loader2 size={14} className="spin" /> : <GitMerge size={14} />}
|
||||
{busy ? <Loader2 size={14} className="ws-spin" /> : <GitMerge size={14} />}
|
||||
Save alignment
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -23,9 +23,7 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) {
|
||||
setRegistry(entries);
|
||||
setSelectedUri((current) => current || entries[0]?.uri || "");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
|
||||
});
|
||||
.catch(() => { /* backend unavailable — leave registry empty */ });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -37,9 +35,9 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) {
|
||||
setError("");
|
||||
try {
|
||||
setHealth(await loadOntologyHealth(uri));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Backend unavailable — show "select an ontology" placeholder, not an error
|
||||
setHealth(null);
|
||||
setError(err instanceof Error ? err.message : "Could not load ontology health.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -84,7 +82,7 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) {
|
||||
{error ? <div style={errorStyle}>{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<div style={loadingStyle}><Loader2 size={18} className="spin" /> Computing health dashboard...</div>
|
||||
<div style={loadingStyle}><Loader2 size={18} className="ws-spin" /> Computing health dashboard...</div>
|
||||
) : health ? (
|
||||
<>
|
||||
<section style={{ ...scoreGridStyle, gridTemplateColumns: `220px repeat(${health.dimensions.length}, minmax(180px, 1fr))` }}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookMarked,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
Layers,
|
||||
Loader2,
|
||||
Plus,
|
||||
@@ -241,7 +241,6 @@ function RegistryRow({
|
||||
export function OntologyManager() {
|
||||
const [entries, setEntries] = useState<OntologyEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQ, setSearchQ] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [showLoader, setShowLoader] = useState(false);
|
||||
@@ -251,17 +250,18 @@ export function OntologyManager() {
|
||||
|
||||
const fetchRegistry = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQ) params.set("q", searchQ);
|
||||
// format/kind filters (owl/skos/internal/external) are applied client-side
|
||||
// via filteredEntries; only text search is delegated to the backend
|
||||
const res = await fetch(`/api/ontology/registry?${params}`);
|
||||
if (!res.ok) throw new Error("Failed to load registry");
|
||||
setEntries(await res.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load registry");
|
||||
if (res.ok) {
|
||||
setEntries(await res.json());
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
} catch {
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -429,25 +429,23 @@ export function OntologyManager() {
|
||||
<Loader2 size={22} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
|
||||
<span style={{ color: "#8fa8c6", fontSize: 13, marginTop: 10 }}>Loading registry…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={centerStyle}>
|
||||
<AlertCircle size={22} color="#ff9daf" />
|
||||
<span style={{ color: "#ff9daf", fontSize: 13, marginTop: 8 }}>{error}</span>
|
||||
<button onClick={fetchRegistry} style={retryBtnStyle}>Retry</button>
|
||||
</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div style={emptyStateStyle}>
|
||||
<GitMerge size={36} color="rgba(74,163,255,0.15)" />
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, marginTop: 12 }}>
|
||||
<BookMarked size={36} color="rgba(74,163,255,0.18)" />
|
||||
<div style={{ color: "#8fa8c6", fontSize: 14, fontWeight: 600, marginTop: 14 }}>
|
||||
{searchQ ? "No ontologies match your search" : "No ontologies loaded yet"}
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
|
||||
Click <strong style={{ color: "#7fd0ff" }}>Load Ontology</strong> to import from a URL, upload a file, or create a new ontology.
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 6, textAlign: "center", maxWidth: 300, lineHeight: 1.6 }}>
|
||||
{searchQ
|
||||
? "Try a different search term or clear the filter."
|
||||
: <>Import from a URL, upload a file, or create a new ontology to get started. Click <strong style={{ color: "#7fd0ff" }}>Load Ontology</strong> above.</>}
|
||||
</div>
|
||||
<button onClick={() => setShowLoader(true)} style={{ ...primaryToolBtnStyle, marginTop: 16 }}>
|
||||
<Plus size={13} />
|
||||
Load Ontology
|
||||
</button>
|
||||
{!searchQ && (
|
||||
<button onClick={() => setShowLoader(true)} style={{ ...primaryToolBtnStyle, marginTop: 18 }}>
|
||||
<Plus size={13} />
|
||||
Load Ontology
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={listStyle}>
|
||||
@@ -902,14 +900,3 @@ const emptyStateStyle: React.CSSProperties = {
|
||||
...centerStyle,
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
const retryBtnStyle: React.CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "6px 14px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.18)",
|
||||
background: "transparent",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
@@ -33,9 +33,7 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
|
||||
setRegistry(entries);
|
||||
setSelectedUri((current) => current || entries[0]?.uri || "");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
|
||||
});
|
||||
.catch(() => { /* backend unavailable — leave registry empty */ });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -53,8 +51,9 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
|
||||
setShacl((current) => current || turtle);
|
||||
setSelectedShapeId(null);
|
||||
setValidation(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not load SHACL shapes.");
|
||||
} catch {
|
||||
// Shapes not yet generated or backend unavailable — show empty shape list
|
||||
setShapes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -131,45 +130,50 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
|
||||
}, [shapes]);
|
||||
|
||||
const beforeMount = useCallback((monaco: Monaco) => {
|
||||
if (!monaco.languages.getLanguages().some((language: { id: string }) => language.id === "turtle")) {
|
||||
monaco.languages.register({ id: "turtle", extensions: [".ttl"], mimetypes: ["text/turtle"] });
|
||||
monaco.languages.setMonarchTokensProvider("turtle", {
|
||||
keywords: ["@prefix", "@base", "a"],
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/#[^\n]*/, "comment"],
|
||||
[/"(?:[^"\\]|\\.)*"(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
|
||||
[/'(?:[^'\\]|\\.)*'(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
|
||||
[/"""[\s\S]*?"""/, "string"],
|
||||
[/<[^>]*>/, "type.identifier"],
|
||||
[/\b(?:@prefix|@base|a)\b/, "keyword"],
|
||||
[/\b(?:sh|xsd|owl|rdf|rdfs|skos):[\w]+/, "variable"],
|
||||
[/[a-zA-Z_][\w-]*:[\w]+/, "namespace"],
|
||||
[/[;,.]/, "delimiter"],
|
||||
[/\d+(?:\.\d+)?/, "number"],
|
||||
],
|
||||
try {
|
||||
if (!monaco.languages.getLanguages().some((language: { id: string }) => language.id === "turtle")) {
|
||||
monaco.languages.register({ id: "turtle", extensions: [".ttl"], mimetypes: ["text/turtle"] });
|
||||
monaco.languages.setMonarchTokensProvider("turtle", {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/#[^\n]*/, "comment"],
|
||||
[/"(?:[^"\\]|\\.)*"/, "string"],
|
||||
[/'(?:[^'\\]|\\.)*'/, "string"],
|
||||
[/"""[\s\S]*?"""/, "string"],
|
||||
[/<[^>]*>/, "type.identifier"],
|
||||
// Use [@] to avoid Monarch treating @ as a language-property reference
|
||||
[/[@](?:prefix|base)\b/, "keyword"],
|
||||
[/\ba\b/, "keyword"],
|
||||
[/\b(?:sh|xsd|owl|rdf|rdfs|skos):[\w]+/, "variable"],
|
||||
[/[a-zA-Z_][\w-]*:[\w]+/, "namespace"],
|
||||
[/[;,.]/, "delimiter"],
|
||||
[/\d+(?:\.\d+)?/, "number"],
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
monaco.editor.defineTheme("shacl-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "9ee8d7" },
|
||||
{ token: "string", foreground: "f2b66d" },
|
||||
{ token: "comment", foreground: "4a6070", fontStyle: "italic" },
|
||||
{ token: "type.identifier", foreground: "7ce7d3" },
|
||||
{ token: "variable", foreground: "d2a8ff" },
|
||||
{ token: "namespace", foreground: "a5d6ff" },
|
||||
{ token: "number", foreground: "79c0ff" },
|
||||
{ token: "delimiter", foreground: "8fa8c6" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#050b13",
|
||||
"editor.foreground": "#d7e7f8",
|
||||
"editorLineNumber.foreground": "#41536b",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Monaco setup failure should not crash the component
|
||||
}
|
||||
monaco.editor.defineTheme("shacl-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "9ee8d7" },
|
||||
{ token: "string", foreground: "f2b66d" },
|
||||
{ token: "comment", foreground: "4a6070", fontStyle: "italic" },
|
||||
{ token: "type.identifier", foreground: "7ce7d3" },
|
||||
{ token: "variable", foreground: "d2a8ff" },
|
||||
{ token: "namespace", foreground: "a5d6ff" },
|
||||
{ token: "number", foreground: "79c0ff" },
|
||||
{ token: "delimiter", foreground: "8fa8c6" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#050b13",
|
||||
"editor.foreground": "#d7e7f8",
|
||||
"editorLineNumber.foreground": "#41536b",
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -250,7 +254,7 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={secondaryButtonStyle} disabled={loading} onClick={handleGenerate}><Wand2 size={14} /> Generate strict</button>
|
||||
<button style={primaryButtonStyle} disabled={loading || !shacl.trim()} onClick={handleValidate}>
|
||||
{loading ? <Loader2 size={14} className="spin" /> : <Play size={14} />}
|
||||
{loading ? <Loader2 size={14} className="ws-spin" /> : <Play size={14} />}
|
||||
Validate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -54,33 +54,6 @@ function writeTabParam(tab: OntologyHubTab) {
|
||||
}
|
||||
}
|
||||
|
||||
function ComingSoonStub({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
}: {
|
||||
icon: typeof GitMerge;
|
||||
title: string;
|
||||
description: string;
|
||||
badge: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={stubShellStyle}>
|
||||
<div style={stubCardStyle}>
|
||||
<div style={stubIconRingStyle}>
|
||||
<Icon size={28} color="#7fd0ff" />
|
||||
</div>
|
||||
<div style={stubBadgeStyle}>{badge}</div>
|
||||
<h2 style={stubTitleStyle}>{title}</h2>
|
||||
<p style={stubDescStyle}>{description}</p>
|
||||
<div style={stubDividerStyle} />
|
||||
<p style={stubSubnoteStyle}>Coming in Subissue 2 / 3 of Ontology Hub</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OntologyWorkspaceProps {
|
||||
onJumpToGraphNode?: (nodeId: string) => void;
|
||||
}
|
||||
@@ -122,150 +95,25 @@ export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
<div style={tabBarStyle}>
|
||||
{TABS.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
style={{
|
||||
...tabBtnBase,
|
||||
...(activeTab === id ? tabBtnActive : tabBtnIdle),
|
||||
}}
|
||||
onClick={() => handleTabChange(id)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="ws-page">
|
||||
{/* Internal sub-tab bar */}
|
||||
<div style={{ display: "flex", gap: 4, padding: "8px 16px", borderBottom: "1px solid var(--ws-border)", background: "rgba(0,0,0,0.18)", flexShrink: 0, flexWrap: "wrap" }}>
|
||||
{TABS.map(({ id, label, icon: Icon }) => {
|
||||
const active = activeTab === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => handleTabChange(id)}
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 13px", borderRadius: 999, border: `1px solid ${active ? "var(--ws-border-strong)" : "transparent"}`, background: active ? "var(--ws-accent-soft)" : "transparent", color: active ? "var(--ws-text)" : "var(--ws-text-muted)", fontSize: 12, fontWeight: 600, cursor: "pointer", transition: "160ms ease" }}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={contentStyle}>{renderTab()}</div>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>{renderTab()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#07111f",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const tabBarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
padding: "10px 18px",
|
||||
borderBottom: "1px solid rgba(140,192,255,0.12)",
|
||||
background: "rgba(3,9,18,0.72)",
|
||||
flexShrink: 0,
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const tabBtnBase: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 13px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
transition: "160ms ease",
|
||||
background: "transparent",
|
||||
};
|
||||
|
||||
const tabBtnIdle: React.CSSProperties = {
|
||||
color: "#8fa8c6",
|
||||
borderColor: "rgba(127,208,255,0.1)",
|
||||
};
|
||||
|
||||
const tabBtnActive: React.CSSProperties = {
|
||||
color: "#ebf3ff",
|
||||
background: "rgba(74,163,255,0.16)",
|
||||
borderColor: "rgba(127,208,255,0.3)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const stubShellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "linear-gradient(180deg, rgba(7,17,31,0.8), rgba(5,11,21,0.95))",
|
||||
};
|
||||
|
||||
const stubCardStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "48px 52px",
|
||||
borderRadius: 28,
|
||||
border: "1px solid rgba(127,208,255,0.12)",
|
||||
background: "rgba(9,19,34,0.82)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.06)",
|
||||
maxWidth: 480,
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
const stubIconRingStyle: React.CSSProperties = {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: "50%",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "rgba(74,163,255,0.1)",
|
||||
border: "1px solid rgba(127,208,255,0.18)",
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const stubBadgeStyle: React.CSSProperties = {
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(242,182,109,0.1)",
|
||||
border: "1px solid rgba(242,182,109,0.22)",
|
||||
color: "#f2b66d",
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const stubTitleStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#ebf3ff",
|
||||
fontSize: 22,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const stubDescStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#8fa8c6",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.65,
|
||||
maxWidth: 360,
|
||||
};
|
||||
|
||||
const stubDividerStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
background: "rgba(127,208,255,0.08)",
|
||||
};
|
||||
|
||||
const stubSubnoteStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#5a7a9a",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BrainCircuit, Play, RotateCcw, CheckCircle2, AlertCircle, Zap, GitBranch, Info } from "lucide-react";
|
||||
|
||||
const SAMPLE_FACTS = `inhibits(Metformin, mTOR)
|
||||
causes(mTOR, Neurodegeneration)
|
||||
treats(Metformin, Diabetes)`;
|
||||
|
||||
const SAMPLE_FACTS = `inhibits(Metformin, mTOR)\ncauses(mTOR, Neurodegeneration)`;
|
||||
const SAMPLE_RULE = `IF inhibits(Metformin, mTOR) AND causes(mTOR, Neurodegeneration) THEN candidate(Metformin, Alzheimer's)`;
|
||||
|
||||
const TEMPLATES = [
|
||||
{
|
||||
label: "Drug Candidate",
|
||||
facts: `inhibits(Metformin, mTOR)\ncauses(mTOR, Neurodegeneration)\ntreats(Metformin, Diabetes)`,
|
||||
rule: `IF inhibits(Metformin, mTOR) AND causes(mTOR, Neurodegeneration) THEN candidate(Metformin, Alzheimer's)`,
|
||||
},
|
||||
{
|
||||
label: "Gene → Disease",
|
||||
facts: `expressed_in(BRCA1, Breast)\nmutated_in(BRCA1, Cancer)\nassociated_with(Breast, Cancer)`,
|
||||
rule: `IF mutated_in(X, Cancer) AND expressed_in(X, Y) THEN risk_gene(X, Y)`,
|
||||
},
|
||||
{
|
||||
label: "Pathway Activation",
|
||||
facts: `activates(EGF, EGFR)\ndownstream_of(MAPK, EGFR)\ndownstream_of(AKT, EGFR)`,
|
||||
rule: `IF activates(X, EGFR) AND downstream_of(Y, EGFR) THEN activates(X, Y)`,
|
||||
},
|
||||
];
|
||||
|
||||
export function ReasoningWorkspace() {
|
||||
const queryClient = useQueryClient();
|
||||
const [facts, setFacts] = useState(SAMPLE_FACTS);
|
||||
const [rules, setRules] = useState(SAMPLE_RULE);
|
||||
const [applyToGraph, setApplyToGraph] = useState(true);
|
||||
const [result, setResult] = useState<{ inferred_facts?: string[]; rules_fired?: number; added_edges?: number; mutated?: boolean } | null>(null);
|
||||
const [result, setResult] = useState<{
|
||||
inferred_facts?: string[];
|
||||
rules_fired?: number;
|
||||
added_edges?: number;
|
||||
mutated?: boolean;
|
||||
} | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -22,141 +49,194 @@ export function ReasoningWorkspace() {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
facts: facts.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
rules: rules.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
facts: facts.split(/\r?\n/).map((l) => l.trim()).filter(Boolean),
|
||||
rules: rules.split(/\r?\n/).map((l) => l.trim()).filter(Boolean),
|
||||
mode: "forward",
|
||||
apply_to_graph: applyToGraph,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `Reasoning failed with status ${response.status}`);
|
||||
}
|
||||
if (!response.ok) throw new Error(data.detail || `Status ${response.status}`);
|
||||
setResult(data);
|
||||
if (data.mutated) {
|
||||
queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
|
||||
}
|
||||
} catch (runError) {
|
||||
setError(runError instanceof Error ? runError.message : "Reasoning failed");
|
||||
if (data.mutated) queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Reasoning failed");
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function loadTemplate(t: (typeof TEMPLATES)[number]) {
|
||||
setFacts(t.facts);
|
||||
setRules(t.rule);
|
||||
setResult(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setFacts(SAMPLE_FACTS);
|
||||
setRules(SAMPLE_RULE);
|
||||
setResult(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.2fr 1fr", gap: 24, height: "100%", padding: 24, boxSizing: "border-box", background: "#0d1117" }}>
|
||||
<div style={panelStyle}>
|
||||
<h3 style={titleStyle}>Facts</h3>
|
||||
<p style={copyStyle}>Enter one fact per line using `predicate(subject, object)` form.</p>
|
||||
<textarea value={facts} onChange={(event) => setFacts(event.target.value)} style={textareaStyle} />
|
||||
<div className="ws-page" style={{ flexDirection: "row" }}>
|
||||
{/* ── Left: Input panel ── */}
|
||||
<div style={{ width: 480, flexShrink: 0, display: "flex", flexDirection: "column", borderRight: "1px solid var(--ws-border)", overflow: "hidden" }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: "18px 20px 14px", borderBottom: "1px solid var(--ws-border)", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 2 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 10, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
|
||||
<BrainCircuit size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="ws-eyebrow" style={{ marginBottom: 2 }}>Forward Chaining</div>
|
||||
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 15, lineHeight: 1 }}>Inference Engine</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ ...titleStyle, marginTop: 18 }}>Rules</h3>
|
||||
<p style={copyStyle}>Write rules in `IF ... AND ... THEN ...` format. If the advanced reasoner is unavailable, the explorer falls back to an internal rule matcher for this format.</p>
|
||||
<textarea value={rules} onChange={(event) => setRules(event.target.value)} style={{ ...textareaStyle, minHeight: 160 }} />
|
||||
{/* Templates */}
|
||||
<div style={{ padding: "12px 16px 10px", borderBottom: "1px solid var(--ws-border)", flexShrink: 0 }}>
|
||||
<div className="ws-eyebrow" style={{ marginBottom: 8 }}>Quick Templates</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{TEMPLATES.map((t) => (
|
||||
<button key={t.label} className="ws-btn ws-btn--ghost" style={{ padding: "5px 10px", fontSize: 11 }} onClick={() => loadTemplate(t)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, color: "#c9d1d9", fontSize: 13, marginTop: 16 }}>
|
||||
<input type="checkbox" checked={applyToGraph} onChange={(event) => setApplyToGraph(event.target.checked)} />
|
||||
Write inferred binary facts back into the graph as inferred edges
|
||||
</label>
|
||||
{/* Input area */}
|
||||
<div className="ws-scroll ws-padded" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div>
|
||||
<label className="ws-label">Facts</label>
|
||||
<div className="ws-body" style={{ marginBottom: 8 }}>One fact per line using <code style={{ color: "var(--ws-accent)", fontSize: 11 }}>predicate(subject, object)</code> form.</div>
|
||||
<textarea
|
||||
className="ws-textarea"
|
||||
value={facts}
|
||||
onChange={(e) => setFacts(e.target.value)}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button onClick={handleRun} disabled={isRunning} style={runButtonStyle}>
|
||||
{isRunning ? "Running..." : "Run Reasoning"}
|
||||
</button>
|
||||
<div>
|
||||
<label className="ws-label">Rules</label>
|
||||
<div className="ws-body" style={{ marginBottom: 8 }}>Use <code style={{ color: "var(--ws-amber)", fontSize: 11 }}>IF … AND … THEN …</code> syntax. Falls back to internal matcher if the reasoning server is unavailable.</div>
|
||||
<textarea
|
||||
className="ws-textarea"
|
||||
value={rules}
|
||||
onChange={(e) => setRules(e.target.value)}
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Apply toggle */}
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", padding: "10px 12px", borderRadius: "var(--ws-radius-sm)", border: "1px solid var(--ws-border)", background: applyToGraph ? "var(--ws-green-soft)" : "var(--ws-surface)" }}>
|
||||
<div style={{ position: "relative", width: 36, height: 20, flexShrink: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyToGraph}
|
||||
onChange={(e) => setApplyToGraph(e.target.checked)}
|
||||
style={{ opacity: 0, position: "absolute", inset: 0, cursor: "pointer", margin: 0 }}
|
||||
/>
|
||||
<div style={{ position: "absolute", inset: 0, borderRadius: 999, background: applyToGraph ? "var(--ws-green)" : "rgba(255,255,255,0.12)", transition: "background 180ms ease" }} />
|
||||
<div style={{ position: "absolute", top: 3, left: applyToGraph ? 19 : 3, width: 14, height: 14, borderRadius: 999, background: "#fff", transition: "left 180ms ease", boxShadow: "0 1px 4px rgba(0,0,0,0.4)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: applyToGraph ? "#6ee7b7" : "var(--ws-text-muted)" }}>Write inferred facts to graph</div>
|
||||
<div style={{ fontSize: 11, color: "var(--ws-text-dim)" }}>Inferred binary facts are added as edges</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="ws-btn ws-btn--primary"
|
||||
onClick={handleRun}
|
||||
disabled={isRunning}
|
||||
style={{ flex: 1, justifyContent: "center" }}
|
||||
>
|
||||
{isRunning
|
||||
? <><span className="ws-spin" style={{ display: "inline-block" }}><Zap size={15} /></span>Running…</>
|
||||
: <><Play size={14} />Run Reasoning</>}
|
||||
</button>
|
||||
<button className="ws-btn ws-btn--ghost" onClick={handleReset} title="Reset to defaults">
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={panelStyle}>
|
||||
<h3 style={titleStyle}>Inference Results</h3>
|
||||
|
||||
{error ? <div style={{ color: "#ff7b72", marginBottom: 12 }}>{error}</div> : null}
|
||||
|
||||
{result ? (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 14 }}>
|
||||
<span style={pillStyle}>rules fired: {result.rules_fired ?? 0}</span>
|
||||
<span style={pillStyle}>edges added: {result.added_edges ?? 0}</span>
|
||||
<span style={pillStyle}>{result.mutated ? "graph updated" : "preview only"}</span>
|
||||
{/* ── Right: Results panel ── */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div style={{ padding: "18px 24px 14px", borderBottom: "1px solid var(--ws-border)", flexShrink: 0, display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: "var(--ws-text)" }}>Inference Results</div>
|
||||
{result && !isRunning && (
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
|
||||
<span className="ws-pill ws-pill--accent">
|
||||
<Zap size={9} /> {result.rules_fired ?? 0} rules fired
|
||||
</span>
|
||||
<span className="ws-pill ws-pill--green">
|
||||
<GitBranch size={9} /> {result.added_edges ?? 0} edges added
|
||||
</span>
|
||||
{result.mutated
|
||||
? <span className="ws-pill ws-pill--green">graph updated</span>
|
||||
: <span className="ws-pill ws-pill--mono">preview only</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ws-scroll ws-padded" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{error && (
|
||||
<div className="ws-animate-in" style={{ display: "flex", gap: 10, padding: "12px 14px", borderRadius: "var(--ws-radius-sm)", background: "var(--ws-red-soft)", border: "1px solid rgba(255,123,114,0.28)", color: "#fca5a5", fontSize: 13 }}>
|
||||
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{(result.inferred_facts || []).length ? (
|
||||
result.inferred_facts?.map((fact) => (
|
||||
<div key={fact} style={factCardStyle}>{fact}</div>
|
||||
))
|
||||
{[1,2,3,4].map((i) => <div key={i} className="ws-skeleton" style={{ height: 52 }} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !isRunning && (
|
||||
<div className="ws-animate-in">
|
||||
{(result.inferred_facts ?? []).length === 0 ? (
|
||||
<div className="ws-empty">
|
||||
<div className="ws-empty-icon"><CheckCircle2 size={32} /></div>
|
||||
<div className="ws-empty-title">Reasoning complete</div>
|
||||
<div className="ws-empty-body">No new facts were inferred from the current rule set and facts.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", fontSize: 13 }}>No inferred facts were produced.</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{result.inferred_facts!.map((fact, i) => (
|
||||
<div key={`${fact}-${i}`} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "12px 14px", borderRadius: "var(--ws-radius-sm)", background: "var(--ws-surface)", border: "1px solid var(--ws-border)" }}>
|
||||
<div style={{ width: 20, height: 20, borderRadius: 6, background: "var(--ws-green-soft)", border: "1px solid rgba(76,195,138,0.28)", display: "grid", placeItems: "center", flexShrink: 0, marginTop: 1 }}>
|
||||
<CheckCircle2 size={11} color="var(--ws-green)" />
|
||||
</div>
|
||||
<code style={{ fontFamily: "'JetBrains Mono','Fira Code',monospace", fontSize: 12, color: "var(--ws-text)", lineHeight: 1.6, wordBreak: "break-all" }}>{fact}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", fontSize: 13 }}>Run a rule set to inspect inferred statements here.</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!result && !isRunning && !error && (
|
||||
<div className="ws-empty">
|
||||
<div className="ws-empty-icon"><Info size={32} /></div>
|
||||
<div className="ws-empty-title">Ready to reason</div>
|
||||
<div className="ws-empty-body">Enter facts and rules on the left, then click Run Reasoning to see inferred statements here.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(13, 17, 23, 0.78), rgba(22, 27, 34, 0.64))",
|
||||
border: "1px solid rgba(88, 166, 255, 0.18)",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
color: "#fff",
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const copyStyle: React.CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: "8px 0 14px",
|
||||
};
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
minHeight: 120,
|
||||
resize: "vertical",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(88, 166, 255, 0.18)",
|
||||
background: "rgba(0, 0, 0, 0.25)",
|
||||
color: "#e6edf3",
|
||||
padding: 12,
|
||||
fontFamily: "Consolas, monospace",
|
||||
fontSize: 13,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const runButtonStyle: React.CSSProperties = {
|
||||
marginTop: 18,
|
||||
border: "1px solid rgba(88, 166, 255, 0.3)",
|
||||
background: "rgba(31, 111, 235, 0.2)",
|
||||
color: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "11px 14px",
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const pillStyle: React.CSSProperties = {
|
||||
color: "#79c0ff",
|
||||
border: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
borderRadius: 999,
|
||||
padding: "5px 10px",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const factCardStyle: React.CSSProperties = {
|
||||
color: "#e6edf3",
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
borderRadius: 10,
|
||||
padding: "10px 12px",
|
||||
fontFamily: "Consolas, monospace",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
@@ -1,149 +1,260 @@
|
||||
/**
|
||||
* src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx
|
||||
*/
|
||||
import { useState, useRef } from "react";
|
||||
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||
import { Play, Copy, Download, Table2, AlertCircle, FileCode2 } from "lucide-react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
const TEMPLATES: { label: string; query: string }[] = [
|
||||
{ label: "All triples", query: "SELECT ?s ?p ?o\nWHERE {\n ?s ?p ?o\n}\nLIMIT 20" },
|
||||
{ label: "Node types", query: "SELECT ?type (COUNT(?s) AS ?count)\nWHERE {\n ?s a ?type\n}\nGROUP BY ?type\nORDER BY DESC(?count)" },
|
||||
{ label: "Outgoing edges", query: "SELECT ?predicate ?object\nWHERE {\n <urn:node:example> ?predicate ?object\n}\nLIMIT 50" },
|
||||
{ label: "Path between", query: "SELECT ?mid ?p1 ?p2\nWHERE {\n <urn:node:a> ?p1 ?mid .\n ?mid ?p2 <urn:node:b>\n}\nLIMIT 20" },
|
||||
];
|
||||
|
||||
export function SparqlWorkspace() {
|
||||
const monaco = useMonaco();
|
||||
const editorRef = useRef<any>(null);
|
||||
const [query, setQuery] = useState("SELECT ?s ?p ?o\nWHERE {\n ?s ?p ?o\n}\nLIMIT 10");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const editorRef = useRef<unknown>(null);
|
||||
const [query, setQuery] = useState(TEMPLATES[0].query);
|
||||
const [result, setResult] = useState<{ columns?: string[]; rows?: Record<string, string>[]; error?: string; error_line?: number } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [copyState, setCopyState] = useState(false);
|
||||
|
||||
function handleEditorWillMount(monacoIns: any) {
|
||||
if (!monacoIns.languages.getLanguages().some((l: any) => l.id === "sparql")) {
|
||||
function handleEditorWillMount(monacoIns: { languages: { getLanguages(): { id: string }[]; register(opts: { id: string }): void; setMonarchTokensProvider(id: string, p: unknown): void }; editor: { defineTheme(id: string, t: unknown): void } }) {
|
||||
if (!monacoIns.languages.getLanguages().some((l) => l.id === "sparql")) {
|
||||
monacoIns.languages.register({ id: "sparql" });
|
||||
|
||||
monacoIns.languages.setMonarchTokensProvider("sparql", {
|
||||
keywords: ["SELECT", "WHERE", "LIMIT", "FILTER", "OPTIONAL", "PREFIX", "ORDER BY", "DESC", "ASC"],
|
||||
keywords: ["SELECT", "WHERE", "LIMIT", "FILTER", "OPTIONAL", "PREFIX", "ORDER", "BY", "DESC", "ASC", "GROUP", "DISTINCT", "CONSTRUCT", "ASK", "DESCRIBE"],
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/[a-zA-Z_]\w*/, { cases: { "@keywords": "keyword", "@default": "identifier" } }],
|
||||
[/[?\$][a-zA-Z_]\w*/, "variable.name"],
|
||||
[/[?$][a-zA-Z_]\w*/, "variable.name"],
|
||||
[/<[^>]+>/, "string.uri"],
|
||||
[/".*?"/, "string"],
|
||||
[/"[^"]*"/, "string"],
|
||||
[/#.*/, "comment"],
|
||||
]
|
||||
}
|
||||
[/[0-9]+(\.[0-9]+)?/, "number"],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
monacoIns.editor.defineTheme("sparql-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "58a6ff", fontStyle: "bold" },
|
||||
{ token: "variable.name", foreground: "79c0ff" },
|
||||
{ token: "string.uri", foreground: "a5d6ff" },
|
||||
{ token: "variable.name", foreground: "a5d6ff" },
|
||||
{ token: "string.uri", foreground: "7ee787" },
|
||||
{ token: "string", foreground: "a5d6ff" },
|
||||
{ token: "comment", foreground: "8b949e" }
|
||||
{ token: "comment", foreground: "4a6a85", fontStyle: "italic" },
|
||||
{ token: "number", foreground: "f2b66d" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#0d1117",
|
||||
"editor.lineHighlightBackground": "#161b22",
|
||||
}
|
||||
"editor.background": "#050c18",
|
||||
"editor.lineHighlightBackground": "#0a1628",
|
||||
"editorLineNumber.foreground": "#2a4060",
|
||||
"editorCursor.foreground": "#4aa3ff",
|
||||
"editor.selectionBackground": "#1e3a5a",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorDidMount(editor: any) {
|
||||
function handleEditorDidMount(editor: unknown) {
|
||||
editorRef.current = editor;
|
||||
}
|
||||
|
||||
async function handleRun() {
|
||||
setIsLoading(true);
|
||||
setResult(null);
|
||||
monaco?.editor.setModelMarkers(editorRef.current.getModel(), "sparql", []);
|
||||
|
||||
if (monaco && editorRef.current) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(monaco as any).editor.setModelMarkers((editorRef.current as any).getModel(), "sparql", []);
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/sparql", {
|
||||
const res = await fetch("/api/sparql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query })
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
if (data.error_line && monaco && editorRef.current) {
|
||||
monaco.editor.setModelMarkers(editorRef.current.getModel(), "sparql", [
|
||||
{
|
||||
startLineNumber: data.error_line,
|
||||
startColumn: data.error_column || 1,
|
||||
endLineNumber: data.error_line,
|
||||
endColumn: 100,
|
||||
message: data.error,
|
||||
severity: monaco.MarkerSeverity.Error
|
||||
}
|
||||
]);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.error && data.error_line && monaco && editorRef.current) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(monaco as any).editor.setModelMarkers((editorRef.current as any).getModel(), "sparql", [{
|
||||
startLineNumber: data.error_line,
|
||||
startColumn: data.error_column || 1,
|
||||
endLineNumber: data.error_line,
|
||||
endColumn: 100,
|
||||
message: data.error,
|
||||
severity: (monaco as any).MarkerSeverity.Error,
|
||||
}]);
|
||||
}
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch {
|
||||
setResult({ error: "Network error — could not reach the SPARQL endpoint." });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyQuery() {
|
||||
navigator.clipboard.writeText(query).then(() => {
|
||||
setCopyState(true);
|
||||
setTimeout(() => setCopyState(false), 1500);
|
||||
}).catch(() => {
|
||||
// Clipboard API unavailable (insecure context or denied) — no-op; query is visible in editor
|
||||
});
|
||||
}
|
||||
|
||||
function handleExportCSV() {
|
||||
if (!result?.rows || !result?.columns) return;
|
||||
const cols = result.columns;
|
||||
const header = cols.join(",");
|
||||
const rows = result.rows.map((r) => cols.map((c) => JSON.stringify(r[c] ?? "")).join(",")).join("\n");
|
||||
const blob = new Blob([`${header}\n${rows}`], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "sparql_results.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 24, boxSizing: "border-box", gap: 24 }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h2 style={{ color: "#ffffff", margin: 0, fontSize: 24 }}>SPARQL Query Engine</h2>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isLoading}
|
||||
style={{ background: "#238636", color: "#fff", border: "none", padding: "8px 24px", borderRadius: 6, fontWeight: 600, cursor: "pointer" }}
|
||||
>
|
||||
{isLoading ? "Running..." : "Run Query"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="ws-page">
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||||
{/* ── Toolbar ── */}
|
||||
<div style={{ padding: "10px 16px", borderBottom: "1px solid var(--ws-border)", display: "flex", alignItems: "center", gap: 8, flexShrink: 0, background: "rgba(0,0,0,0.18)" }}>
|
||||
<div style={{ display: "flex", gap: 6, flex: 1, flexWrap: "wrap" }}>
|
||||
<span className="ws-eyebrow" style={{ alignSelf: "center", marginRight: 4 }}>Templates:</span>
|
||||
{TEMPLATES.map((t) => (
|
||||
<button
|
||||
key={t.label}
|
||||
className="ws-btn ws-btn--ghost"
|
||||
style={{ padding: "4px 10px", fontSize: 11 }}
|
||||
onClick={() => { setQuery(t.query); setResult(null); }}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="ws-btn ws-btn--ghost" style={{ padding: "6px 10px" }} onClick={handleCopyQuery} title="Copy query">
|
||||
<Copy size={13} />{copyState ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
className="ws-btn ws-btn--primary"
|
||||
onClick={handleRun}
|
||||
disabled={isLoading}
|
||||
style={{ minWidth: 110, justifyContent: "center" }}
|
||||
>
|
||||
{isLoading
|
||||
? <><span className="ws-spin" style={{ display: "inline-block" }}><Play size={13} /></span>Running…</>
|
||||
: <><Play size={13} />Run Query</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, overflow: "hidden", border: "1px solid rgba(88,166,255,0.2)" }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sparql"
|
||||
theme="sparql-dark"
|
||||
value={query}
|
||||
onChange={(v) => setQuery(v || "")}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{ minimap: { enabled: false }, fontSize: 14, fontFamily: "monospace" }}
|
||||
/>
|
||||
</div>
|
||||
{/* ── Editor + Results split ── */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Editor */}
|
||||
<div style={{ flex: "0 0 55%", minHeight: 0, borderBottom: "1px solid var(--ws-border)", position: "relative" }}>
|
||||
<div style={{ position: "absolute", top: 8, right: 12, zIndex: 10, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span className="ws-pill ws-pill--mono"><FileCode2 size={9} />SPARQL</span>
|
||||
</div>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sparql"
|
||||
theme="sparql-dark"
|
||||
value={query}
|
||||
onChange={(v) => setQuery(v || "")}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: "'JetBrains Mono','Fira Code',Consolas,monospace",
|
||||
lineHeight: 22,
|
||||
padding: { top: 16 },
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: "gutter",
|
||||
wordWrap: "on",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel" style={{ height: "30%", borderRadius: 12, padding: 16, overflowY: "auto" }}>
|
||||
<h3 style={{ color: "#ffffff", margin: "0 0 16px 0", fontSize: 16 }}>Results</h3>
|
||||
{result?.error ? (
|
||||
<div style={{ color: "#ff7b72" }}>{result.error}</div>
|
||||
) : result?.rows ? (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", color: "#c9d1d9" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
{result.columns.map((c: string) => <th key={c} style={{ textAlign: "left", padding: 8 }}>{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((r: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
{result.columns.map((c: string) => <td key={c} style={{ padding: 8 }}>{r[c]}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e" }}>No results to display. Run a query first.</div>
|
||||
)}
|
||||
{/* Results */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden", background: "rgba(0,0,0,0.12)" }}>
|
||||
<div style={{ padding: "10px 16px", borderBottom: "1px solid var(--ws-border)", display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, color: "var(--ws-text-muted)", fontSize: 13, fontWeight: 700 }}>
|
||||
<Table2 size={14} />
|
||||
Results
|
||||
{result?.rows && <span className="ws-pill ws-pill--accent">{result.rows.length} rows</span>}
|
||||
</div>
|
||||
{result?.rows && result.rows.length > 0 && (
|
||||
<button className="ws-btn ws-btn--ghost" style={{ marginLeft: "auto", padding: "4px 10px", fontSize: 11 }} onClick={handleExportCSV}>
|
||||
<Download size={12} />Export CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ws-scroll" style={{ flex: 1 }}>
|
||||
{isLoading && (
|
||||
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[1, 2, 3].map((i) => <div key={i} className="ws-skeleton" style={{ height: 36 }} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.error && !isLoading && (
|
||||
<div className="ws-animate-in" style={{ margin: 16, display: "flex", gap: 10, padding: "12px 14px", borderRadius: "var(--ws-radius-sm)", background: "var(--ws-red-soft)", border: "1px solid rgba(255,123,114,0.28)", color: "#fca5a5", fontSize: 13 }}>
|
||||
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.rows && result?.columns && !isLoading && (
|
||||
<div className="ws-animate-in" style={{ overflowX: "auto" }}>
|
||||
{result.rows.length === 0 ? (
|
||||
<div className="ws-empty">
|
||||
<div className="ws-empty-title">No results</div>
|
||||
<div className="ws-empty-body">The query returned 0 rows. Try a broader query or check your data.</div>
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12, color: "var(--ws-text)" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--ws-border)", background: "rgba(0,0,0,0.2)" }}>
|
||||
<th style={{ padding: "8px 14px", textAlign: "left", color: "var(--ws-text-dim)", fontFamily: "monospace", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", width: 40 }}>#</th>
|
||||
{result.columns.map((c) => (
|
||||
<th key={c} style={{ padding: "8px 14px", textAlign: "left", color: "var(--ws-text-muted)", fontFamily: "monospace", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em" }}>?{c}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((r, i) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid rgba(74,163,255,0.06)" }}>
|
||||
<td style={{ padding: "7px 14px", color: "var(--ws-text-dim)", fontFamily: "monospace", fontSize: 11 }}>{i + 1}</td>
|
||||
{(result.columns ?? []).map((c) => (
|
||||
<td key={c} style={{ padding: "7px 14px", maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "monospace" }} title={String(r[c] ?? "")}>
|
||||
{r[c] != null ? (
|
||||
String(r[c]).startsWith("urn:") || String(r[c]).startsWith("http")
|
||||
? <span style={{ color: "#7ee787" }}>{String(r[c])}</span>
|
||||
: String(r[c])
|
||||
) : <span style={{ color: "var(--ws-text-dim)", fontStyle: "italic" }}>null</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && !isLoading && (
|
||||
<div className="ws-empty">
|
||||
<div className="ws-empty-icon"><Table2 size={28} /></div>
|
||||
<div className="ws-empty-title">Run a query</div>
|
||||
<div className="ws-empty-body">Write SPARQL above or pick a template, then click Run Query to see results here.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -160,7 +160,7 @@ test("summarizeDistanceBuckets reports local rings and outside count", () => {
|
||||
anchor: 1,
|
||||
oneHop: 1,
|
||||
twoHop: 1,
|
||||
threeHop: 1,
|
||||
threeHopPlus: 1,
|
||||
outside: 2,
|
||||
});
|
||||
});
|
||||
@@ -193,11 +193,11 @@ test("buildHeatmapRenderSnapshot caps and deterministically samples large rings"
|
||||
assert.equal(firstSnapshot.ringCounts.anchor, 1);
|
||||
assert.equal(firstSnapshot.ringCounts.oneHop, 130);
|
||||
assert.equal(firstSnapshot.ringCounts.twoHop, 700);
|
||||
assert.equal(firstSnapshot.ringCounts.threeHop, 950);
|
||||
assert.equal(firstSnapshot.ringCounts.threeHopPlus, 950);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.anchor, 1);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.oneHop, 120);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.twoHop, 650);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.threeHop, 900);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.threeHopPlus, 900);
|
||||
assert.equal(firstSnapshot.saturationMode, "sampled");
|
||||
assert.deepEqual(firstSnapshot.visibleNodeIds, secondSnapshot.visibleNodeIds);
|
||||
assert.ok(firstSnapshot.visibleNodeIds.includes("anchor"));
|
||||
@@ -233,7 +233,7 @@ test("resolveDistanceNodeStyle applies readable heatmap rings only when ready",
|
||||
anchor: 1,
|
||||
oneHop: 1,
|
||||
twoHop: 1,
|
||||
threeHop: 1,
|
||||
threeHopPlus: 1,
|
||||
outside: 1,
|
||||
},
|
||||
});
|
||||
@@ -268,7 +268,7 @@ test("resolveDistanceNodeStyle compresses saturated heatmap far rings", () => {
|
||||
anchor: 1,
|
||||
oneHop: 32,
|
||||
twoHop: 3350,
|
||||
threeHop: 7412,
|
||||
threeHopPlus: 7412,
|
||||
outside: 3280,
|
||||
},
|
||||
});
|
||||
@@ -295,14 +295,14 @@ test("resolveDistanceNodeStyle mutes unsampled heatmap nodes instead of coloring
|
||||
anchor: 1,
|
||||
oneHop: 0,
|
||||
twoHop: 2,
|
||||
threeHop: 0,
|
||||
threeHopPlus: 0,
|
||||
outside: 0,
|
||||
},
|
||||
heatmapRenderedRingCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 0,
|
||||
twoHop: 1,
|
||||
threeHop: 0,
|
||||
threeHopPlus: 0,
|
||||
outside: 0,
|
||||
},
|
||||
heatmapSaturationMode: "sampled",
|
||||
|
||||
Reference in New Issue
Block a user