mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 20:51:49 +00:00
init
This commit is contained in:
242
thirdeye/dashboard/app/knowledge-base/EntityPanel.tsx
Normal file
242
thirdeye/dashboard/app/knowledge-base/EntityPanel.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "./knowledge.css";
|
||||
import {
|
||||
fetchGroups,
|
||||
queryKnowledge,
|
||||
Group,
|
||||
fetchSignals,
|
||||
Signal,
|
||||
parseMetaList,
|
||||
} from "../lib/api";
|
||||
|
||||
export default function EntityPanel() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [selectedGroup, setSelectedGroup] = useState<string>("");
|
||||
const [queryResult, setQueryResult] = useState<string | null>(null);
|
||||
const [querying, setQuerying] = useState(false);
|
||||
const [topSignals, setTopSignals] = useState<Signal[]>([]);
|
||||
const [loadingSignals, setLoadingSignals] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups().then((grps) => {
|
||||
setGroups(grps);
|
||||
if (grps.length > 0) {
|
||||
setSelectedGroup(grps[0].group_id);
|
||||
loadSignals(grps[0].group_id);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(groupId: string) {
|
||||
setLoadingSignals(true);
|
||||
try {
|
||||
const sigs = await fetchSignals(groupId);
|
||||
setTopSignals(sigs.slice(0, 2));
|
||||
} catch {
|
||||
setTopSignals([]);
|
||||
} finally {
|
||||
setLoadingSignals(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleGroupChange = (groupId: string) => {
|
||||
setSelectedGroup(groupId);
|
||||
setQueryResult(null);
|
||||
loadSignals(groupId);
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim() || !selectedGroup) return;
|
||||
setQuerying(true);
|
||||
setQueryResult(null);
|
||||
try {
|
||||
const res = await queryKnowledge(selectedGroup, searchQuery);
|
||||
setQueryResult(res.answer);
|
||||
} catch {
|
||||
setQueryResult("⚠ Backend unavailable or group has no signals yet.");
|
||||
} finally {
|
||||
setQuerying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedGroupData = groups.find((g) => g.group_id === selectedGroup);
|
||||
const topEntities = topSignals
|
||||
.flatMap((s) => parseMetaList(s.metadata.entities))
|
||||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3 animate-fade-in-right">
|
||||
{/* Group Selector */}
|
||||
{groups.length > 1 && (
|
||||
<div className="px-4 py-3 rounded-[1.25rem] bg-[#110D1A]/90 backdrop-blur-2xl border border-[#A78BFA]/10 shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
|
||||
<p className="text-[9px] font-mono-data text-[#A78BFA]/60 uppercase tracking-widest mb-2">
|
||||
Select Group
|
||||
</p>
|
||||
<select
|
||||
value={selectedGroup}
|
||||
onChange={(e) => handleGroupChange(e.target.value)}
|
||||
className="w-full bg-transparent text-[#E9D9FF] text-[12px] font-medium focus:outline-none cursor-pointer"
|
||||
style={{ appearance: "none" }}
|
||||
>
|
||||
{groups.map((g) => (
|
||||
<option key={g.group_id} value={g.group_id} style={{ backgroundColor: "#110D1A" }}>
|
||||
{g.group_name} ({g.signal_count} signals)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Header */}
|
||||
<div className="px-4 py-3.5 rounded-[1.25rem] bg-[#110D1A]/90 backdrop-blur-2xl border border-[#A78BFA]/10 flex items-center gap-3 shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="material-symbols-outlined text-[#A78BFA]/50 text-[20px] hover:text-[#A78BFA] transition-colors"
|
||||
disabled={querying}
|
||||
>
|
||||
{querying ? "hourglass_empty" : "search"}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="Query knowledge base..."
|
||||
className="flex-1 bg-transparent text-[#E9D9FF] placeholder-[#8B7BB1] focus:outline-none text-[13px] font-medium tracking-wide"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setQueryResult(null);
|
||||
}}
|
||||
className="material-symbols-outlined text-[#A78BFA]/50 text-[20px] hover:text-[#A78BFA] transition-colors"
|
||||
>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.25rem] flex flex-col shadow-[0_16px_48px_rgba(0,0,0,0.6)] bg-[#110D1A]/95 backdrop-blur-2xl border border-[#A78BFA]/10 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-6 relative bg-[#1A132B]/30">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className="text-[9px] font-mono-data bg-[#2D1B4E]/80 text-[#B79FFF] px-2.5 py-1 rounded-md tracking-[0.15em] border border-[#A78BFA]/20">
|
||||
{selectedGroupData ? "ACTIVE_GROUP" : "NO_GROUP"}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[#8B7BB1] text-[20px] cursor-pointer hover:text-white transition-colors">
|
||||
close
|
||||
</span>
|
||||
</div>
|
||||
<h2
|
||||
className="text-[20px] font-bold text-white tracking-tight leading-none mb-1.5 shadow-sm"
|
||||
style={{ fontFamily: "'Space Grotesk', sans-serif" }}
|
||||
>
|
||||
{selectedGroup ? selectedGroup.replace(/-/g, "_").toUpperCase() : "Select a group"}
|
||||
</h2>
|
||||
<p className="text-[11px] text-[#8B7BB1] font-mono-data opacity-90 tracking-wide">
|
||||
{selectedGroupData
|
||||
? `${selectedGroupData.signal_count} signals · lens: ${selectedGroupData.lens || "unknown"}`
|
||||
: "No data"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-[1px] bg-gradient-to-r from-transparent via-[#A78BFA]/10 to-transparent" />
|
||||
|
||||
{/* Query Result */}
|
||||
{queryResult && (
|
||||
<>
|
||||
<div className="p-6 bg-[#0C0814]/60">
|
||||
<h4 className="text-[9px] font-mono-data text-[#8B7BB1] uppercase tracking-[0.2em] mb-3 opacity-90">
|
||||
Query Result
|
||||
</h4>
|
||||
<p className="text-[12px] text-[#E9D9FF] leading-relaxed">
|
||||
{queryResult}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full h-[1px] bg-gradient-to-r from-transparent via-[#A78BFA]/10 to-transparent" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-7 relative">
|
||||
{/* Primary Insights */}
|
||||
<div>
|
||||
<h4 className="text-[9px] font-mono-data text-[#8B7BB1] uppercase tracking-[0.2em] mb-2.5 opacity-90">
|
||||
Signal Overview
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-[#0C0814]/80 border border-[#A78BFA]/5 shadow-inner">
|
||||
<p className="text-[8px] font-mono-data text-[#8B7BB1] mb-1.5 uppercase tracking-[0.15em] opacity-80">
|
||||
Signals
|
||||
</p>
|
||||
<p className="text-[16px] font-mono-data text-[#00daf3] font-bold tracking-tight drop-shadow-[0_0_8px_rgba(0,218,243,0.2)]">
|
||||
{selectedGroupData?.signal_count ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-[#0C0814]/80 border border-[#A78BFA]/5 shadow-inner">
|
||||
<p className="text-[8px] font-mono-data text-[#8B7BB1] mb-1.5 uppercase tracking-[0.15em] opacity-80">
|
||||
Lens
|
||||
</p>
|
||||
<p className="text-[14px] font-mono-data text-[#E9D9FF] font-bold tracking-tight uppercase">
|
||||
{selectedGroupData?.lens?.slice(0, 8) ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Entities */}
|
||||
{topEntities.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[9px] font-mono-data text-[#8B7BB1] uppercase tracking-[0.2em] mb-3 opacity-90">
|
||||
Recent Entities
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topEntities.map((entity, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] font-mono-data text-[#B79FFF] bg-[#2D1B4E]/60 px-2.5 py-1 rounded-md border border-[#A78BFA]/20"
|
||||
>
|
||||
{entity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loadingSignals && (
|
||||
<div className="flex items-center gap-2 text-zinc-600 text-[10px] font-mono-data">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">autorenew</span>
|
||||
Loading signals...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loadingSignals && selectedGroupData?.signal_count === 0 && (
|
||||
<p className="text-[11px] text-zinc-600 font-mono-data">
|
||||
No signals yet. Send messages to the monitored Telegram group to populate the knowledge base.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Action */}
|
||||
<div className="p-5 border-t border-[#A78BFA]/10 bg-[#0C0814]/40">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!searchQuery.trim() || querying}
|
||||
className="w-full bg-[#1A102A] hover:bg-[#2D1B4E] border border-[#A78BFA]/20 text-[#B79FFF] hover:text-white font-mono-data text-[9.5px] font-bold tracking-[0.25em] py-3.5 rounded-xl transition-all uppercase flex items-center justify-center gap-3 group relative overflow-hidden btn-interactive shadow-lg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="relative z-10">
|
||||
{querying ? "Querying..." : "Execute Deep Query"}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[16px] relative z-10 group-hover:scale-110 transition-transform text-[#00daf3]">
|
||||
{querying ? "hourglass_empty" : "bolt"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
thirdeye/dashboard/app/knowledge-base/FloatingControls.tsx
Normal file
76
thirdeye/dashboard/app/knowledge-base/FloatingControls.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "./knowledge.css";
|
||||
import { fetchAllSignals, Signal, formatRelativeTime } from "../lib/api";
|
||||
|
||||
export default function FloatingControls() {
|
||||
const [activeView, setActiveView] = useState("Graph View");
|
||||
const [recentSignals, setRecentSignals] = useState<Signal[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const all = await fetchAllSignals();
|
||||
const flat = all
|
||||
.flatMap((g) => g.signals)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.metadata.timestamp).getTime() -
|
||||
new Date(a.metadata.timestamp).getTime()
|
||||
)
|
||||
.slice(0, 6); // enough for seamless looping
|
||||
setRecentSignals(flat);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
// visual feedback only
|
||||
console.log(`Graph action: ${action}`);
|
||||
};
|
||||
|
||||
// Format a signal into a short log line
|
||||
function signalToLogLine(sig: Signal): string {
|
||||
const type = sig.metadata.type.toUpperCase().replace(/_/g, "_");
|
||||
const groupShort = sig.metadata.group_id.split("-").slice(-1)[0]?.toUpperCase() || "?";
|
||||
const time = formatRelativeTime(sig.metadata.timestamp);
|
||||
return `[${time}] ${type} · ${groupShort}`;
|
||||
}
|
||||
|
||||
// Fallback static lines when no data
|
||||
const fallbackLines = [
|
||||
"[waiting] NODE_ATTACH",
|
||||
"[waiting] EDGE_UPDATE",
|
||||
"[waiting] SIGNAL_LOCKED",
|
||||
];
|
||||
|
||||
const logLines =
|
||||
recentSignals.length > 0
|
||||
? recentSignals.map(signalToLogLine)
|
||||
: fallbackLines;
|
||||
|
||||
// Duplicate for seamless scroll
|
||||
const displayLines = [...logLines, ...logLines];
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-4 flex items-end gap-4 z-40 animate-fade-in-up delay-100">
|
||||
{/* View Toggle & Graph Controls */}
|
||||
|
||||
|
||||
<style dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes scroll-up {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(-50%); }
|
||||
}
|
||||
`,
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
548
thirdeye/dashboard/app/knowledge-base/KnowledgeBrowser.tsx
Normal file
548
thirdeye/dashboard/app/knowledge-base/KnowledgeBrowser.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
fetchGroups,
|
||||
fetchKnowledgeBrowse,
|
||||
Group,
|
||||
Signal,
|
||||
KnowledgeBrowseResponse,
|
||||
KnowledgeDayEntry,
|
||||
KnowledgeTopicSummary,
|
||||
parseMetaList,
|
||||
formatRelativeTime,
|
||||
} from "../lib/api";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const LENS_COLOR: Record<string, string> = {
|
||||
dev: "#00daf3",
|
||||
product: "#A78BFA",
|
||||
client: "#fbbf24",
|
||||
community: "#34d399",
|
||||
meet: "#f87171",
|
||||
jira: "#fb923c",
|
||||
};
|
||||
|
||||
const SEVERITY_STYLE: Record<string, { color: string; bg: string }> = {
|
||||
critical: { color: "#ff6f78", bg: "rgba(255,111,120,0.12)" },
|
||||
high: { color: "#ffb300", bg: "rgba(255,179,0,0.12)" },
|
||||
medium: { color: "#A78BFA", bg: "rgba(167,139,250,0.12)" },
|
||||
low: { color: "#6B7280", bg: "rgba(107,114,128,0.10)" },
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
architecture_decision: "Architecture",
|
||||
tech_debt: "Tech Debt",
|
||||
knowledge_silo_evidence: "Knowledge Silo",
|
||||
recurring_bug: "Bug",
|
||||
stack_decision: "Stack",
|
||||
deployment_risk: "Deploy Risk",
|
||||
workaround: "Workaround",
|
||||
delivery_commitment: "Commitment",
|
||||
feature_request: "Feature",
|
||||
user_pain_point: "Pain Point",
|
||||
roadmap_drift: "Roadmap",
|
||||
priority_conflict: "Conflict",
|
||||
metric_mention: "Metric",
|
||||
user_quote: "Quote",
|
||||
competitor_intel: "Competitor",
|
||||
promise: "Promise",
|
||||
scope_creep: "Scope Creep",
|
||||
sentiment_signal: "Sentiment",
|
||||
unanswered_request: "Unanswered",
|
||||
satisfaction: "Satisfaction",
|
||||
escalation_risk: "Escalation",
|
||||
client_decision: "Client Decision",
|
||||
meet_decision: "Decision",
|
||||
meet_action_item: "Action Item",
|
||||
meet_blocker: "Blocker",
|
||||
meet_risk: "Risk",
|
||||
meet_summary: "Summary",
|
||||
};
|
||||
|
||||
function typeLabel(t: string) {
|
||||
return TYPE_LABEL[t] ?? t.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
if (!iso || iso === "unknown") return "Unknown";
|
||||
const d = new Date(iso + (iso.includes("T") ? "" : "T00:00:00"));
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };
|
||||
if (d.toDateString() === today.toDateString()) return "Today · " + d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
if (d.toDateString() === yesterday.toDateString()) return "Yesterday · " + d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString("en-US", opts);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Signal Card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SignalCard({ signal, expanded, onToggle }: {
|
||||
signal: Signal;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const meta = signal.metadata;
|
||||
const lens = meta.lens || "dev";
|
||||
const lensColor = LENS_COLOR[lens] ?? "#A78BFA";
|
||||
const sev = SEVERITY_STYLE[meta.severity] ?? SEVERITY_STYLE.low;
|
||||
const entities = parseMetaList(meta.entities).slice(0, 3);
|
||||
const keywords = parseMetaList(meta.keywords).slice(0, 3);
|
||||
const summary = meta.summary || signal.document || "";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full text-left rounded-xl border transition-all duration-200 group"
|
||||
style={{
|
||||
backgroundColor: expanded ? "rgba(167,139,250,0.06)" : "rgba(11,8,18,0.7)",
|
||||
borderColor: expanded ? "rgba(167,139,250,0.25)" : "rgba(167,139,250,0.08)",
|
||||
}}
|
||||
>
|
||||
{/* Top row */}
|
||||
<div className="flex items-center justify-between px-3 pt-2.5 pb-1 gap-2">
|
||||
<span
|
||||
className="text-[9px] font-mono font-bold px-2 py-0.5 rounded-md tracking-wider uppercase shrink-0"
|
||||
style={{ color: lensColor, backgroundColor: `${lensColor}18`, border: `1px solid ${lensColor}28` }}
|
||||
>
|
||||
{typeLabel(meta.type)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span
|
||||
className="text-[8px] font-mono font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wider"
|
||||
style={{ color: sev.color, backgroundColor: sev.bg }}
|
||||
>
|
||||
{meta.severity}
|
||||
</span>
|
||||
<span className="text-[8px] text-[#6B7280] font-mono">{fmtTime(meta.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<p
|
||||
className="px-3 pb-2 text-[11px] text-[#C4B5F4] leading-relaxed font-medium"
|
||||
style={{ display: "-webkit-box", WebkitLineClamp: expanded ? 999 : 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}
|
||||
>
|
||||
{summary}
|
||||
</p>
|
||||
|
||||
{/* Entities + raw quote on expand */}
|
||||
{expanded && (
|
||||
<div className="px-3 pb-2.5 space-y-2">
|
||||
{meta.raw_quote && (
|
||||
<p className="text-[10px] text-[#8B7BB1] italic border-l-2 pl-2 leading-relaxed" style={{ borderColor: lensColor + "60" }}>
|
||||
"{meta.raw_quote}"
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entities.map((e, i) => (
|
||||
<span key={i} className="text-[9px] font-mono text-[#A78BFA] bg-[#2D1B4E]/50 px-1.5 py-0.5 rounded border border-[#A78BFA]/15">
|
||||
{e}
|
||||
</span>
|
||||
))}
|
||||
{keywords.map((k, i) => (
|
||||
<span key={i} className="text-[9px] font-mono text-[#6B7280] bg-[#1A1A2E]/50 px-1.5 py-0.5 rounded border border-white/5">
|
||||
#{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entities preview (collapsed) */}
|
||||
{!expanded && entities.length > 0 && (
|
||||
<div className="px-3 pb-2.5 flex flex-wrap gap-1">
|
||||
{entities.map((e, i) => (
|
||||
<span key={i} className="text-[9px] font-mono text-[#8B7BB1]">{e}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Day Section ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DaySection({ entry, activeTopic }: { entry: KnowledgeDayEntry; activeTopic: string | null }) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const visibleSignals = activeTopic
|
||||
? entry.signals.filter((s) => {
|
||||
try {
|
||||
const kws: string[] = JSON.parse(s.metadata.keywords || "[]");
|
||||
return kws.map((k) => k.toLowerCase().trim()).includes(activeTopic.toLowerCase()) ||
|
||||
s.metadata.type.replace(/_/g, " ").toLowerCase() === activeTopic.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
: entry.signals;
|
||||
|
||||
if (visibleSignals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{/* Day header */}
|
||||
<div className="flex items-center gap-2 mb-2 sticky top-0 z-10 py-1"
|
||||
style={{ background: "linear-gradient(to bottom, #09090B, transparent)" }}>
|
||||
<div className="h-px w-2 bg-[#A78BFA]/30" />
|
||||
<span className="text-[9px] font-mono font-bold text-[#A78BFA]/70 uppercase tracking-[0.2em] shrink-0">
|
||||
{fmtDate(entry.date)}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-[#A78BFA]/10" />
|
||||
<span className="text-[8px] font-mono text-[#6B7280] shrink-0">{visibleSignals.length} signals</span>
|
||||
</div>
|
||||
|
||||
{/* Topic chips for this day */}
|
||||
{entry.topics.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2 px-0.5">
|
||||
{entry.topics.slice(0, 5).map((t, i) => (
|
||||
<span key={i} className="text-[8px] font-mono text-[#6B7280] bg-[#1A1A2E]/60 px-1.5 py-0.5 rounded-md border border-white/5">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signal cards */}
|
||||
<div className="space-y-1.5">
|
||||
{visibleSignals.map((sig) => (
|
||||
<SignalCard
|
||||
key={sig.id}
|
||||
signal={sig}
|
||||
expanded={expandedId === sig.id}
|
||||
onToggle={() => setExpandedId(expandedId === sig.id ? null : sig.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Topic Chips ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TopicChips({
|
||||
topics,
|
||||
activeTopic,
|
||||
onSelect,
|
||||
}: {
|
||||
topics: KnowledgeTopicSummary[];
|
||||
activeTopic: string | null;
|
||||
onSelect: (t: string | null) => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="flex gap-1.5 overflow-x-auto pb-1" style={{ scrollbarWidth: "none" }}>
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className="shrink-0 text-[9px] font-mono font-bold px-2.5 py-1 rounded-lg border transition-all uppercase tracking-wider"
|
||||
style={{
|
||||
color: activeTopic === null ? "#09090B" : "#A78BFA",
|
||||
backgroundColor: activeTopic === null ? "#A78BFA" : "rgba(167,139,250,0.1)",
|
||||
borderColor: activeTopic === null ? "#A78BFA" : "rgba(167,139,250,0.25)",
|
||||
}}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{topics.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
onClick={() => onSelect(activeTopic === t.name ? null : t.name)}
|
||||
className="shrink-0 flex items-center gap-1 text-[9px] font-mono px-2 py-1 rounded-lg border transition-all"
|
||||
style={{
|
||||
color: activeTopic === t.name ? "#09090B" : "#8B7BB1",
|
||||
backgroundColor: activeTopic === t.name ? "#A78BFA" : "rgba(167,139,250,0.06)",
|
||||
borderColor: activeTopic === t.name ? "#A78BFA" : "rgba(167,139,250,0.15)",
|
||||
}}
|
||||
>
|
||||
<span className="truncate max-w-[80px]">{t.name}</span>
|
||||
<span
|
||||
className="shrink-0 text-[8px] font-bold rounded-full w-4 h-4 flex items-center justify-center"
|
||||
style={{
|
||||
color: activeTopic === t.name ? "#09090B" : "#A78BFA",
|
||||
backgroundColor: activeTopic === t.name ? "rgba(0,0,0,0.2)" : "rgba(167,139,250,0.15)",
|
||||
}}
|
||||
>
|
||||
{t.signal_count > 99 ? "99+" : t.signal_count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function KnowledgeBrowser() {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [selectedGroup, setSelectedGroup] = useState<string>("");
|
||||
const [browseData, setBrowseData] = useState<KnowledgeBrowseResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTopic, setActiveTopic] = useState<string | null>(null);
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load groups
|
||||
useEffect(() => {
|
||||
fetchGroups().then((grps) => {
|
||||
setGroups(grps);
|
||||
if (grps.length > 0) setSelectedGroup(grps[0].group_id);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Load browse data when group or date filters change
|
||||
const loadBrowse = useCallback(async (groupId: string, from: string, to: string) => {
|
||||
if (!groupId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setActiveTopic(null);
|
||||
try {
|
||||
const data = await fetchKnowledgeBrowse(groupId, {
|
||||
dateFrom: from || undefined,
|
||||
dateTo: to || undefined,
|
||||
});
|
||||
setBrowseData(data);
|
||||
} catch {
|
||||
setError("Backend unavailable — start the ThirdEye server to browse knowledge.");
|
||||
setBrowseData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroup) loadBrowse(selectedGroup, dateFrom, dateTo);
|
||||
}, [selectedGroup, loadBrowse]);
|
||||
|
||||
const handleApplyDates = () => {
|
||||
if (selectedGroup) loadBrowse(selectedGroup, dateFrom, dateTo);
|
||||
};
|
||||
|
||||
const handleClearDates = () => {
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
if (selectedGroup) loadBrowse(selectedGroup, "", "");
|
||||
};
|
||||
|
||||
const selectedGroupData = groups.find((g) => g.group_id === selectedGroup);
|
||||
|
||||
// Count visible signals for active topic across all days
|
||||
const visibleCount = browseData
|
||||
? activeTopic
|
||||
? browseData.timeline.reduce((acc, day) => {
|
||||
const cnt = day.signals.filter((s) => {
|
||||
try {
|
||||
const kws: string[] = JSON.parse(s.metadata.keywords || "[]");
|
||||
return kws.map((k) => k.toLowerCase().trim()).includes(activeTopic.toLowerCase()) ||
|
||||
s.metadata.type.replace(/_/g, " ").toLowerCase() === activeTopic.toLowerCase();
|
||||
} catch { return false; }
|
||||
}).length;
|
||||
return acc + cnt;
|
||||
}, 0)
|
||||
: browseData.total_signals
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3 animate-fade-in-right">
|
||||
|
||||
{/* ── Group Selector ──────────────────────────────────────────────── */}
|
||||
<div className="px-4 py-3 rounded-[1.25rem] bg-[#110D1A]/90 backdrop-blur-2xl border border-[#A78BFA]/10 shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
|
||||
<p className="text-[9px] font-mono text-[#A78BFA]/60 uppercase tracking-widest mb-1.5">
|
||||
Knowledge Source
|
||||
</p>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-[11px] text-[#6B7280] font-mono">Loading groups…</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedGroup}
|
||||
onChange={(e) => setSelectedGroup(e.target.value)}
|
||||
className="w-full bg-transparent text-[#E9D9FF] text-[12px] font-semibold focus:outline-none cursor-pointer"
|
||||
style={{ appearance: "none" }}
|
||||
>
|
||||
{groups.map((g) => (
|
||||
<option key={g.group_id} value={g.group_id} style={{ backgroundColor: "#110D1A" }}>
|
||||
{g.group_name} — {g.signal_count} signals
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{selectedGroupData && (
|
||||
<p className="text-[9px] font-mono text-[#6B7280] mt-1 uppercase tracking-wider">
|
||||
lens: {selectedGroupData.lens || "unknown"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Date Range Filter ───────────────────────────────────────────── */}
|
||||
<div className="px-4 py-3 rounded-[1.25rem] bg-[#110D1A]/90 backdrop-blur-2xl border border-[#A78BFA]/10 shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
|
||||
<p className="text-[9px] font-mono text-[#A78BFA]/60 uppercase tracking-widest mb-2">
|
||||
Date Range
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="flex-1 bg-[#0C0814]/70 border border-[#A78BFA]/15 rounded-lg text-[10px] font-mono text-[#C4B5F4] px-2 py-1.5 focus:outline-none focus:border-[#A78BFA]/40"
|
||||
style={{ colorScheme: "dark" }}
|
||||
/>
|
||||
<span className="text-[9px] text-[#6B7280] font-mono shrink-0">→</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="flex-1 bg-[#0C0814]/70 border border-[#A78BFA]/15 rounded-lg text-[10px] font-mono text-[#C4B5F4] px-2 py-1.5 focus:outline-none focus:border-[#A78BFA]/40"
|
||||
style={{ colorScheme: "dark" }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleApplyDates}
|
||||
className="shrink-0 material-symbols-outlined text-[18px] text-[#A78BFA]/60 hover:text-[#A78BFA] transition-colors"
|
||||
title="Apply date filter"
|
||||
>
|
||||
filter_alt
|
||||
</button>
|
||||
{(dateFrom || dateTo) && (
|
||||
<button
|
||||
onClick={handleClearDates}
|
||||
className="shrink-0 material-symbols-outlined text-[18px] text-[#6B7280] hover:text-[#ff6f78] transition-colors"
|
||||
title="Clear filter"
|
||||
>
|
||||
close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Browser Panel ────────────────────────────────────────────────── */}
|
||||
<div className="rounded-[1.25rem] flex flex-col shadow-[0_16px_48px_rgba(0,0,0,0.6)] bg-[#110D1A]/95 backdrop-blur-2xl border border-[#A78BFA]/10 overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-4 pb-3 bg-[#1A132B]/30 border-b border-[#A78BFA]/8">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[9px] font-mono bg-[#2D1B4E]/80 text-[#B79FFF] px-2.5 py-1 rounded-md tracking-[0.15em] border border-[#A78BFA]/20 uppercase">
|
||||
Knowledge Browser
|
||||
</span>
|
||||
{loading && (
|
||||
<span className="material-symbols-outlined animate-spin text-[16px] text-[#A78BFA]/50">autorenew</span>
|
||||
)}
|
||||
</div>
|
||||
{browseData && !loading && (
|
||||
<p className="text-[10px] text-[#8B7BB1] font-mono mt-1.5">
|
||||
<span className="text-[#00daf3] font-bold">{visibleCount}</span>
|
||||
{activeTopic ? ` signals · topic: "${activeTopic}"` : ` signals · ${browseData.topics.length} topics`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Topic Chips */}
|
||||
{browseData && browseData.topics.length > 0 && (
|
||||
<div className="px-3 py-2.5 border-b border-[#A78BFA]/8 bg-[#0C0814]/30">
|
||||
<p className="text-[8px] font-mono text-[#6B7280] uppercase tracking-widest mb-1.5">Topics</p>
|
||||
<TopicChips
|
||||
topics={browseData.topics}
|
||||
activeTopic={activeTopic}
|
||||
onSelect={setActiveTopic}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="flex-1 overflow-y-auto px-3 py-3"
|
||||
style={{ maxHeight: "600px", scrollbarWidth: "none" }}
|
||||
>
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
||||
<span className="material-symbols-outlined animate-spin text-[32px] text-[#A78BFA]/40">autorenew</span>
|
||||
<p className="text-[10px] font-mono text-[#6B7280] uppercase tracking-widest">Loading knowledge…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!loading && error && (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-2 text-center">
|
||||
<span className="material-symbols-outlined text-[28px] text-[#ff6f78]/50">cloud_off</span>
|
||||
<p className="text-[10px] font-mono text-[#6B7280] leading-relaxed">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !error && browseData && browseData.timeline.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-center">
|
||||
<span className="material-symbols-outlined text-[32px] text-[#A78BFA]/30">library_books</span>
|
||||
<p className="text-[11px] font-mono text-[#6B7280]">No signals found</p>
|
||||
<p className="text-[9px] font-mono text-[#4B4860]">
|
||||
{dateFrom || dateTo ? "Try adjusting the date range" : "Send messages to populate this group"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline days */}
|
||||
{!loading && !error && browseData && browseData.timeline.map((entry) => (
|
||||
<DaySection key={entry.date} entry={entry} activeTopic={activeTopic} />
|
||||
))}
|
||||
|
||||
{/* No signals for active topic */}
|
||||
{!loading && !error && browseData && activeTopic && visibleCount === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-2 text-center">
|
||||
<span className="material-symbols-outlined text-[28px] text-[#A78BFA]/30">search_off</span>
|
||||
<p className="text-[10px] font-mono text-[#6B7280]">No signals for topic "{activeTopic}"</p>
|
||||
<button
|
||||
onClick={() => setActiveTopic(null)}
|
||||
className="text-[9px] font-mono text-[#A78BFA] hover:text-white transition-colors underline underline-offset-2"
|
||||
>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer stats */}
|
||||
{browseData && !loading && (
|
||||
<div className="px-4 py-2.5 border-t border-[#A78BFA]/8 bg-[#0C0814]/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-center">
|
||||
<p className="text-[14px] font-mono font-bold text-[#00daf3]">{browseData.total_signals}</p>
|
||||
<p className="text-[7px] font-mono text-[#6B7280] uppercase tracking-wider">Signals</p>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-[#A78BFA]/10" />
|
||||
<div className="text-center">
|
||||
<p className="text-[14px] font-mono font-bold text-[#A78BFA]">{browseData.topics.length}</p>
|
||||
<p className="text-[7px] font-mono text-[#6B7280] uppercase tracking-wider">Topics</p>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-[#A78BFA]/10" />
|
||||
<div className="text-center">
|
||||
<p className="text-[14px] font-mono font-bold text-[#B79FFF]">{browseData.timeline.length}</p>
|
||||
<p className="text-[7px] font-mono text-[#6B7280] uppercase tracking-wider">Days</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadBrowse(selectedGroup, dateFrom, dateTo)}
|
||||
className="material-symbols-outlined text-[18px] text-[#A78BFA]/40 hover:text-[#A78BFA] transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
610
thirdeye/dashboard/app/knowledge-base/NetworkMap.tsx
Normal file
610
thirdeye/dashboard/app/knowledge-base/NetworkMap.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import "./knowledge.css";
|
||||
import {
|
||||
fetchGroups,
|
||||
fetchAllSignals,
|
||||
Group,
|
||||
Signal,
|
||||
getSeverityColor,
|
||||
parseMetaList,
|
||||
} from "../lib/api";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NodeDef {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number; // 0..100 percentage
|
||||
y: number; // 0..100 percentage
|
||||
size: "core" | "group" | "signal";
|
||||
icon: string;
|
||||
color: string;
|
||||
tooltip: { title: string; lines: string[] };
|
||||
}
|
||||
|
||||
interface EdgeDef {
|
||||
from: string;
|
||||
to: string;
|
||||
dashed?: boolean;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
// ─── Layout helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const CORE = { x: 52, y: 48 };
|
||||
|
||||
/** Evenly distribute groups in a circle around the core */
|
||||
function groupPositions(count: number) {
|
||||
const positions: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (2 * Math.PI * i) / count - Math.PI / 2;
|
||||
const rx = 28; // ellipse horizontal radius (% units)
|
||||
const ry = 22; // vertical radius
|
||||
positions.push({
|
||||
x: CORE.x + rx * Math.cos(angle),
|
||||
y: CORE.y + ry * Math.sin(angle),
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/** Place signal satellites around their parent group node */
|
||||
function signalPositions(
|
||||
parentX: number,
|
||||
parentY: number,
|
||||
count: number,
|
||||
startAngle: number
|
||||
) {
|
||||
const r = 9;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = startAngle + (2 * Math.PI * i) / count;
|
||||
return {
|
||||
x: parentX + r * Math.cos(angle),
|
||||
y: parentY + r * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const SIGNAL_TYPE_ICONS: Record<string, string> = {
|
||||
security_concern: "security",
|
||||
architecture_decision: "architecture",
|
||||
tech_debt: "construction",
|
||||
consensus: "check_circle",
|
||||
blocker: "block",
|
||||
risk: "warning",
|
||||
sentiment_spike: "mood",
|
||||
knowledge_gap: "help",
|
||||
decision: "gavel",
|
||||
action_item: "task_alt",
|
||||
trend: "trending_up",
|
||||
default: "sensors",
|
||||
};
|
||||
|
||||
function getSignalTypeIcon(type: string): string {
|
||||
return SIGNAL_TYPE_ICONS[type] ?? SIGNAL_TYPE_ICONS.default;
|
||||
}
|
||||
|
||||
// ─── Node Components ──────────────────────────────────────────────────────────
|
||||
|
||||
function CoreNode({ node }: { node: NodeDef }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-auto cursor-pointer group"
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%`, transform: "translate(-50%,-50%)" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-violet-500 rounded-full blur-xl opacity-40 group-hover:opacity-70 transition-opacity" />
|
||||
<div className="relative w-14 h-14 glass-card rounded-xl neon-border flex items-center justify-center rotate-45 group-hover:scale-110 transition-transform bg-[#19191d]/60">
|
||||
<span
|
||||
className="material-symbols-outlined text-violet-400 -rotate-45 block"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
auto_awesome
|
||||
</span>
|
||||
</div>
|
||||
{hovered && (
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-4 z-20 w-52 pointer-events-none">
|
||||
<div className="glass-card neon-border p-3 rounded-lg shadow-2xl bg-[#09090b]/90">
|
||||
<p className="text-[10px] font-mono-data text-violet-400 mb-1">NODE::CORE_AI</p>
|
||||
<p className="text-xs font-bold text-zinc-200">{node.tooltip.title}</p>
|
||||
<div className="h-[1px] bg-violet-500/20 my-2" />
|
||||
{node.tooltip.lines.map((l, i) => (
|
||||
<p key={i} className="text-[9px] font-mono-data text-zinc-500">{l}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupNode({ node }: { node: NodeDef }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-auto cursor-pointer group"
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%`, transform: "translate(-50%,-50%)" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full blur-md opacity-30 group-hover:opacity-60 transition-opacity"
|
||||
style={{ backgroundColor: node.color }}
|
||||
/>
|
||||
<div
|
||||
className="relative w-11 h-11 glass-card rounded-full neon-border flex items-center justify-center group-hover:scale-110 transition-all bg-[#19191d]/60"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg" style={{ color: node.color }}>
|
||||
{node.icon}
|
||||
</span>
|
||||
</div>
|
||||
<p className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-[8px] font-mono-data text-zinc-500 uppercase tracking-tighter group-hover:text-violet-400 whitespace-nowrap max-w-[80px] overflow-hidden text-ellipsis text-center">
|
||||
{node.label}
|
||||
</p>
|
||||
{hovered && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 z-20 w-52 pointer-events-none">
|
||||
<div className="glass-card neon-border p-3 rounded-lg shadow-2xl bg-[#09090b]/90">
|
||||
<p className="text-[10px] font-mono-data mb-1" style={{ color: node.color }}>
|
||||
NODE::GROUP
|
||||
</p>
|
||||
<p className="text-xs font-bold text-zinc-200 mb-2">{node.tooltip.title}</p>
|
||||
<div className="h-[1px] bg-violet-500/20 my-2" />
|
||||
{node.tooltip.lines.map((l, i) => (
|
||||
<p key={i} className="text-[9px] font-mono-data text-zinc-400">{l}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalNode({ node }: { node: NodeDef }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-auto cursor-pointer"
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%`, transform: "translate(-50%,-50%)" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="relative w-7 h-7 glass-card rounded-full flex items-center justify-center hover:scale-125 transition-all"
|
||||
style={{ border: `1px solid ${node.color}40` }}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ color: node.color, fontSize: "13px" }}>
|
||||
{node.icon}
|
||||
</span>
|
||||
</div>
|
||||
{hovered && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 w-44 pointer-events-none">
|
||||
<div className="glass-card p-2.5 rounded-lg shadow-2xl bg-[#09090b]/90" style={{ border: `1px solid ${node.color}30` }}>
|
||||
<p className="text-[9px] font-mono-data mb-0.5" style={{ color: node.color }}>
|
||||
SIGNAL::{node.tooltip.title.toUpperCase().replace(/ /g, "_")}
|
||||
</p>
|
||||
{node.tooltip.lines.map((l, i) => (
|
||||
<p key={i} className="text-[8px] font-mono-data text-zinc-500 leading-relaxed">{l}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkMap() {
|
||||
const [nodes, setNodes] = useState<NodeDef[]>([]);
|
||||
const [edges, setEdges] = useState<EdgeDef[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalSignals, setTotalSignals] = useState(0);
|
||||
|
||||
// ── Pan state ──
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragging = useRef(false);
|
||||
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest(".pointer-events-auto")) return;
|
||||
dragging.current = true;
|
||||
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragging.current) return;
|
||||
const dx = e.clientX - dragStart.current.x;
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => { dragging.current = false; };
|
||||
|
||||
const resetPan = () => setPan({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
async function build() {
|
||||
try {
|
||||
const [groups, allGroupSignals] = await Promise.all([
|
||||
fetchGroups(),
|
||||
fetchAllSignals(),
|
||||
]);
|
||||
|
||||
const sigMap: Record<string, Signal[]> = {};
|
||||
allGroupSignals.forEach((g) => {
|
||||
sigMap[g.group_id] = g.signals;
|
||||
});
|
||||
const total = allGroupSignals.reduce((acc, g) => acc + g.signals.length, 0);
|
||||
setTotalSignals(total);
|
||||
|
||||
const newNodes: NodeDef[] = [];
|
||||
const newEdges: EdgeDef[] = [];
|
||||
|
||||
// Core node
|
||||
const coreId = "core_ai";
|
||||
newNodes.push({
|
||||
id: coreId,
|
||||
label: "ThirdEye AI",
|
||||
x: CORE.x,
|
||||
y: CORE.y,
|
||||
size: "core",
|
||||
icon: "auto_awesome",
|
||||
color: "#A78BFA",
|
||||
tooltip: {
|
||||
title: "ThirdEye Neural Engine",
|
||||
lines: [
|
||||
`GROUPS: ${groups.length}`,
|
||||
`SIGNALS: ${total}`,
|
||||
"STATUS: ACTIVE",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Group nodes
|
||||
const gPositions = groupPositions(Math.max(groups.length, 1));
|
||||
|
||||
const LENS_COLORS: Record<string, string> = {
|
||||
dev: "#00daf3",
|
||||
product: "#A78BFA",
|
||||
client: "#ffb300",
|
||||
community: "#10b981",
|
||||
meet: "#ff6f78",
|
||||
unknown: "#6b7280",
|
||||
};
|
||||
|
||||
groups.forEach((group, gi) => {
|
||||
const pos = gPositions[gi];
|
||||
const signals = sigMap[group.group_id] || [];
|
||||
const color = LENS_COLORS[group.lens] || LENS_COLORS.unknown;
|
||||
const groupId = `group_${group.group_id}`;
|
||||
|
||||
// Count signal types
|
||||
const typeCounts: Record<string, number> = {};
|
||||
signals.forEach((s) => {
|
||||
const t = s.metadata.type;
|
||||
typeCounts[t] = (typeCounts[t] || 0) + 1;
|
||||
});
|
||||
const topTypes = Object.entries(typeCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 3);
|
||||
|
||||
newNodes.push({
|
||||
id: groupId,
|
||||
label: group.group_name,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: "group",
|
||||
icon: "group",
|
||||
color,
|
||||
tooltip: {
|
||||
title: group.group_name,
|
||||
lines: [
|
||||
`LENS: ${group.lens?.toUpperCase() || "UNKNOWN"}`,
|
||||
`SIGNALS: ${group.signal_count}`,
|
||||
...topTypes.map(([t, c]) => `${t.replace(/_/g, " ")}: ${c}`),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Edge: core → group
|
||||
newEdges.push({ from: coreId, to: groupId, opacity: 0.8 });
|
||||
|
||||
// Signal satellite nodes (top 3 signal types)
|
||||
const angleSeed = (gi / Math.max(groups.length, 1)) * Math.PI * 2;
|
||||
const sPositions = signalPositions(pos.x, pos.y, topTypes.length, angleSeed);
|
||||
|
||||
topTypes.forEach(([type, count], si) => {
|
||||
const sigNodeId = `sig_${gi}_${si}`;
|
||||
const severity =
|
||||
signals.find((s) => s.metadata.type === type)?.metadata.severity || "low";
|
||||
newNodes.push({
|
||||
id: sigNodeId,
|
||||
label: type,
|
||||
x: sPositions[si].x,
|
||||
y: sPositions[si].y,
|
||||
size: "signal",
|
||||
icon: getSignalTypeIcon(type),
|
||||
color: getSeverityColor(severity),
|
||||
tooltip: {
|
||||
title: type.replace(/_/g, " "),
|
||||
lines: [`COUNT: ${count}`, `SEVERITY: ${severity.toUpperCase()}`],
|
||||
},
|
||||
});
|
||||
newEdges.push({ from: groupId, to: sigNodeId, dashed: true, opacity: 0.5 });
|
||||
});
|
||||
|
||||
// Cross-edges between adjacent groups (every even group connects to next)
|
||||
if (gi > 0 && gi % 2 === 0 && groups.length > 2) {
|
||||
newEdges.push({
|
||||
from: `group_${groups[gi - 1].group_id}`,
|
||||
to: groupId,
|
||||
dashed: true,
|
||||
opacity: 0.2,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// If no groups yet, show a placeholder set
|
||||
if (groups.length === 0) {
|
||||
["dev", "product", "client"].forEach((lens, i) => {
|
||||
const pos = gPositions[i] || { x: 30 + i * 20, y: 45 };
|
||||
const gId = `placeholder_${lens}`;
|
||||
newNodes.push({
|
||||
id: gId,
|
||||
label: `${lens}_group`,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: "group",
|
||||
icon: "group_off",
|
||||
color: "#3f3f46",
|
||||
tooltip: {
|
||||
title: `${lens.toUpperCase()} Group`,
|
||||
lines: ["No data yet", "Connect Telegram to populate"],
|
||||
},
|
||||
});
|
||||
newEdges.push({ from: coreId, to: gId, dashed: true, opacity: 0.15 });
|
||||
});
|
||||
}
|
||||
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
build();
|
||||
}, []);
|
||||
|
||||
// Build a quick lookup for node positions
|
||||
const nodePos: Record<string, { x: number; y: number }> = {};
|
||||
nodes.forEach((n) => { nodePos[n.id] = { x: n.x, y: n.y }; });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0 overflow-hidden"
|
||||
style={{ cursor: dragging.current ? "grabbing" : "grab" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Pan hint */}
|
||||
{!loading && (pan.x !== 0 || pan.y !== 0) && (
|
||||
<button
|
||||
className="absolute top-3 left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 px-3 py-1 rounded-full bg-black/60 border border-violet-500/30 text-[9px] font-mono-data text-violet-400 hover:text-white hover:bg-violet-900/40 transition-all pointer-events-auto"
|
||||
onClick={resetPan}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">center_focus_weak</span>
|
||||
RESET VIEW
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Pannable graph content */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px)`, willChange: "transform" }}
|
||||
>
|
||||
{/* SVG Edges */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none" style={{ overflow: "visible" }}>
|
||||
<defs>
|
||||
{/* Strong glow – core edge packets */}
|
||||
<filter id="glow-strong" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
{/* Subtle glow – secondary edges */}
|
||||
<filter id="glow-subtle" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{edges.map((edge, i) => {
|
||||
const from = nodePos[edge.from];
|
||||
const to = nodePos[edge.to];
|
||||
if (!from || !to) return null;
|
||||
|
||||
const isCoreEdge = !edge.dashed;
|
||||
// Speed: 0.7 – 1.3 s per edge, staggered so packets never sync
|
||||
const duration = 0.7 + (i * 0.18) % 0.6;
|
||||
const phase = (t: number) => -(i * t % Math.round(duration * 1000));
|
||||
|
||||
if (isCoreEdge) {
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Solid base track */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="rgba(255,255,255,0.12)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Violet ambient trace */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#A78BFA"
|
||||
strokeWidth="2"
|
||||
opacity="0.45"
|
||||
filter="url(#glow-subtle)"
|
||||
/>
|
||||
{/* Packet 1 – bright white, leading */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="14 220"
|
||||
opacity="1"
|
||||
filter="url(#glow-strong)"
|
||||
style={{ animation: `data-packet ${duration}s linear infinite`, animationDelay: `${phase(130)}ms` }}
|
||||
/>
|
||||
{/* Packet 2 – violet, ~1/3 behind */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#A78BFA"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="10 220"
|
||||
opacity="0.85"
|
||||
filter="url(#glow-strong)"
|
||||
style={{ animation: `data-packet ${duration}s linear infinite`, animationDelay: `${phase(130) - Math.round(duration * 333)}ms` }}
|
||||
/>
|
||||
{/* Packet 3 – cyan, ~2/3 behind */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#00daf3"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="7 220"
|
||||
opacity="0.7"
|
||||
filter="url(#glow-strong)"
|
||||
style={{ animation: `data-packet ${duration}s linear infinite`, animationDelay: `${phase(130) - Math.round(duration * 666)}ms` }}
|
||||
/>
|
||||
{/* Packet 4 – white micro dot, fills the gaps */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="4 220"
|
||||
opacity="0.5"
|
||||
filter="url(#glow-subtle)"
|
||||
style={{ animation: `data-packet ${duration * 0.8}s linear infinite`, animationDelay: `${phase(80) - Math.round(duration * 500)}ms` }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Secondary / dashed edge (group→signal, cross-group) ──────────
|
||||
const dimDuration = 1.2 + (i % 4) * 0.2;
|
||||
const dimPhase = -(i * 60 % Math.round(dimDuration * 1000));
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Static dashed connector */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="rgba(167,139,250,0.35)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3 8"
|
||||
opacity={edge.opacity ?? 0.6}
|
||||
/>
|
||||
{/* Traveling dot 1 */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#A78BFA"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="6 156"
|
||||
opacity="0.9"
|
||||
filter="url(#glow-subtle)"
|
||||
style={{ animation: `data-packet-dim ${dimDuration}s linear infinite`, animationDelay: `${dimPhase}ms` }}
|
||||
/>
|
||||
{/* Traveling dot 2 – offset by half cycle */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="4 156"
|
||||
opacity="0.6"
|
||||
filter="url(#glow-subtle)"
|
||||
style={{ animation: `data-packet-dim ${dimDuration}s linear infinite`, animationDelay: `${dimPhase - Math.round(dimDuration * 500)}ms` }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Nodes */}
|
||||
{!loading && nodes.map((node) => {
|
||||
if (node.size === "core") return <CoreNode key={node.id} node={node} />;
|
||||
if (node.size === "group") return <GroupNode key={node.id} node={node} />;
|
||||
return <SignalNode key={node.id} node={node} />;
|
||||
})}
|
||||
|
||||
{/* Signal Count Watermark */}
|
||||
{!loading && totalSignals > 0 && (
|
||||
<div
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 pointer-events-none z-0"
|
||||
style={{ opacity: 0.07 }}
|
||||
>
|
||||
<p className="font-mono-data text-violet-300 text-[64px] font-bold tracking-tight select-none">
|
||||
{totalSignals.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading Overlay (outside pan transform) */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
|
||||
<div className="text-center">
|
||||
<span className="material-symbols-outlined text-violet-400 text-4xl animate-spin block mb-2">
|
||||
autorenew
|
||||
</span>
|
||||
<p className="text-[10px] font-mono-data text-zinc-600 uppercase tracking-widest">
|
||||
Building knowledge graph...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag hint (shown once when loaded) */}
|
||||
{!loading && pan.x === 0 && pan.y === 0 && (
|
||||
<div className="absolute bottom-[140px] left-1/2 -translate-x-1/2 pointer-events-none z-10 opacity-30">
|
||||
<p className="text-[8px] font-mono-data text-zinc-500 uppercase tracking-widest flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[11px]">drag_pan</span>
|
||||
drag to pan graph
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
thirdeye/dashboard/app/knowledge-base/RightPanelTabs.tsx
Normal file
45
thirdeye/dashboard/app/knowledge-base/RightPanelTabs.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import EntityPanel from "./EntityPanel";
|
||||
import KnowledgeBrowser from "./KnowledgeBrowser";
|
||||
|
||||
type Tab = "entity" | "browser";
|
||||
|
||||
export default function RightPanelTabs() {
|
||||
const [active, setActive] = useState<Tab>("browser");
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-0">
|
||||
{/* Tab switcher */}
|
||||
<div className="flex items-center gap-1 mb-3 p-1 rounded-xl bg-[#0C0814]/80 border border-[#A78BFA]/10 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => setActive("browser")}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-[9px] font-mono font-bold uppercase tracking-[0.15em] transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: active === "browser" ? "rgba(167,139,250,0.18)" : "transparent",
|
||||
color: active === "browser" ? "#E9D9FF" : "#6B7280",
|
||||
border: active === "browser" ? "1px solid rgba(167,139,250,0.25)" : "1px solid transparent",
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">library_books</span>
|
||||
Knowledge
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActive("entity")}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-[9px] font-mono font-bold uppercase tracking-[0.15em] transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: active === "entity" ? "rgba(167,139,250,0.18)" : "transparent",
|
||||
color: active === "entity" ? "#E9D9FF" : "#6B7280",
|
||||
border: active === "entity" ? "1px solid rgba(167,139,250,0.25)" : "1px solid transparent",
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">manage_search</span>
|
||||
Query
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Panel content */}
|
||||
{active === "browser" ? <KnowledgeBrowser /> : <EntityPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import "./knowledge.css";
|
||||
import { fetchGroups, fetchAllSignals, Group } from "../lib/api";
|
||||
|
||||
export default function SystemTickerKnowledge() {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [totalSignals, setTotalSignals] = useState(0);
|
||||
const [healthy, setHealthy] = useState(true);
|
||||
const [latency, setLatency] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const [grps, all] = await Promise.all([fetchGroups(), fetchAllSignals()]);
|
||||
setGroups(grps);
|
||||
setTotalSignals(all.flatMap((g) => g.signals).length);
|
||||
setHealthy(true);
|
||||
} catch {
|
||||
setHealthy(false);
|
||||
}
|
||||
setLatency(Date.now() - t0);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const logLine =
|
||||
groups.length > 0
|
||||
? `LOG_STREAM: GROUPS_ACTIVE=${groups.length} // SIGNALS_INDEXED=${totalSignals} // ` +
|
||||
groups.map((g) => `[${g.group_name.toUpperCase()}] LENS=${g.lens?.toUpperCase() || "?"} SIGNALS=${g.signal_count}`).join(" // ") +
|
||||
" // GRAPH_ENGINE=ONLINE // VECTOR_DB=CONNECTED"
|
||||
: "LOG_STREAM: Waiting for group connections... Connect Telegram groups to populate the knowledge graph.";
|
||||
|
||||
return (
|
||||
<footer className="fixed bottom-0 left-[240px] right-0 h-8 bg-zinc-950/80 backdrop-blur-md border-t border-violet-500/10 flex items-center px-4 z-50">
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] font-mono-data text-violet-500 font-bold whitespace-nowrap">
|
||||
SYSTEM_STATUS:
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] font-mono-data whitespace-nowrap"
|
||||
style={{ color: healthy ? "#10b981" : "#ff6f78" }}
|
||||
>
|
||||
{healthy ? "OPERATIONAL" : "DEGRADED"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-[1px] bg-zinc-800" />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="text-[9px] font-mono-data text-zinc-500 whitespace-nowrap animate-marquee">
|
||||
{logLine}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 w-[1px] bg-zinc-800" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] font-mono-data text-zinc-500 whitespace-nowrap">NODES:</span>
|
||||
<span className="text-[9px] font-mono-data text-violet-400 whitespace-nowrap">
|
||||
{groups.length + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-[1px] bg-zinc-800" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] font-mono-data text-zinc-500 whitespace-nowrap">LATENCY:</span>
|
||||
<span className="text-[9px] font-mono-data text-violet-400 whitespace-nowrap">
|
||||
{latency !== null ? `${latency}ms` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
59
thirdeye/dashboard/app/knowledge-base/knowledge.css
Normal file
59
thirdeye/dashboard/app/knowledge-base/knowledge.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.glass-card {
|
||||
background: rgba(25, 25, 29, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(167, 139, 250, 0.2);
|
||||
}
|
||||
|
||||
.neon-glow-violet {
|
||||
box-shadow: 0 0 15px rgba(167, 139, 250, 0.15);
|
||||
}
|
||||
|
||||
.neon-border {
|
||||
border: 1px solid rgba(167, 139, 250, 0.4);
|
||||
}
|
||||
|
||||
.node-pulse {
|
||||
animation: pulse 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.grid-bg {
|
||||
background-image: radial-gradient(circle at 2px 2px, rgba(167, 139, 250, 0.05) 1px, transparent 0);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(100%); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
display: inline-block;
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
|
||||
/* Traveling data-packet on core→group edges (period = 14 + 220 = 234px) */
|
||||
@keyframes data-packet {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -234; }
|
||||
}
|
||||
|
||||
/* Traveling dot on group→signal edges (period = 6 + 156 = 162px) */
|
||||
@keyframes data-packet-dim {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -162; }
|
||||
}
|
||||
|
||||
/* Legacy – kept for backward-compat */
|
||||
@keyframes edge-pulse {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -110; }
|
||||
}
|
||||
.edge-pulse-anim {
|
||||
animation: edge-pulse 2s linear infinite;
|
||||
}
|
||||
42
thirdeye/dashboard/app/knowledge-base/page.tsx
Normal file
42
thirdeye/dashboard/app/knowledge-base/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import TopBar from '../components/TopBar';
|
||||
import SystemTickerKnowledge from './SystemTickerKnowledge';
|
||||
import NetworkMap from './NetworkMap';
|
||||
import FloatingControls from './FloatingControls';
|
||||
import RightPanelTabs from './RightPanelTabs';
|
||||
import './knowledge.css';
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
return (
|
||||
<div className="h-screen w-full bg-[#09090B] text-[#F4F4F5] font-['Poppins'] overflow-hidden selection:bg-violet-500/30">
|
||||
<Sidebar />
|
||||
<TopBar />
|
||||
|
||||
{/* Main Content Canvas */}
|
||||
<main className="absolute left-[240px] top-20 right-0 bottom-8 overflow-hidden z-10 flex">
|
||||
|
||||
{/* ── Graph Canvas (fills remaining space) ── */}
|
||||
<div className="relative flex-1 overflow-hidden grid-bg">
|
||||
{/* Crosshair Cursor Elements (Visual Only) */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 pointer-events-none opacity-[0.15] z-10 mix-blend-screen">
|
||||
<div className="absolute top-1/2 left-0 w-full h-[1px] bg-[#a88cfb]/50 shadow-[0_0_10px_#a88cfb]"></div>
|
||||
<div className="absolute left-1/2 top-0 h-full w-[1px] bg-[#a88cfb]/50 shadow-[0_0_10px_#a88cfb]"></div>
|
||||
<div className="absolute inset-0 border border-[#a88cfb]/30 rounded-full scale-50"></div>
|
||||
<div className="absolute inset-0 border border-[#00daf3]/20 rounded-full scale-100 shadow-[0_0_20px_rgba(0,218,243,0.1)_inset]"></div>
|
||||
</div>
|
||||
|
||||
<NetworkMap />
|
||||
<FloatingControls />
|
||||
</div>
|
||||
|
||||
{/* ── Right Panel (fixed width, scrollable) ── */}
|
||||
<div className="w-[520px] flex-shrink-0 overflow-y-auto overflow-x-hidden py-4 pr-4 pl-3 bg-[#09090B]/60 backdrop-blur-sm border-l border-white/[0.04]" style={{ scrollbarWidth: "none" }}>
|
||||
<RightPanelTabs />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<SystemTickerKnowledge />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user