init
41
thirdeye/dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
thirdeye/dashboard/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
208
thirdeye/dashboard/app/agents/AgentCards.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchGroups,
|
||||
fetchSignals,
|
||||
Group,
|
||||
Signal,
|
||||
formatRelativeTime,
|
||||
getSignalIcon,
|
||||
getSeverityColor,
|
||||
} from "../lib/api";
|
||||
|
||||
function GroupAgentCard({ group, signals, index }: { group: Group; signals: Signal[]; index: number }) {
|
||||
const recentSignals = signals.slice(0, 3);
|
||||
const latestTimestamp = signals[0]?.metadata?.timestamp;
|
||||
const criticalCount = signals.filter(
|
||||
(s) => s.metadata.severity === "critical" || s.metadata.severity === "high"
|
||||
).length;
|
||||
|
||||
const isActive = group.signal_count > 0;
|
||||
const statusLabel = isActive ? "ACTIVE" : "IDLE";
|
||||
const statusBg = isActive ? "rgba(16, 185, 129, 0.1)" : "rgba(168, 140, 251, 0.1)";
|
||||
const statusColor = isActive ? "#10b981" : "#a88cfb";
|
||||
|
||||
const gradients = [
|
||||
["#a88cfb", "#00daf3"],
|
||||
["#432390", "#a88cfb"],
|
||||
["#ee7d77", "#ffb300"],
|
||||
["#00daf3", "#a88cfb"],
|
||||
];
|
||||
const [colorA, colorB] = gradients[index % gradients.length];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass rounded-xl border flex flex-col relative overflow-hidden group card-interactive animate-fade-in-up opacity-0"
|
||||
style={{ borderColor: "rgba(255,255,255,0.05)", animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
{/* Top gradient bar */}
|
||||
<div
|
||||
className="h-1 w-full absolute top-0 left-0 opacity-80"
|
||||
style={{ background: `linear-gradient(to right, ${colorA}, ${colorB}, transparent)` }}
|
||||
/>
|
||||
|
||||
<div className="p-6 flex flex-col gap-5 w-full h-full mt-1">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<h4
|
||||
className="font-bold text-[15px] tracking-wide text-white drop-shadow-md"
|
||||
style={{ fontFamily: "'Inter Tight', sans-serif" }}
|
||||
>
|
||||
{group.group_name.toUpperCase()}
|
||||
</h4>
|
||||
<p className="text-[10px] font-mono-data opacity-50" style={{ color: "#a88cfb" }}>
|
||||
LENS: {group.lens?.toUpperCase() || "UNKNOWN"} · {group.signal_count} signals
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="px-2.5 py-0.5 rounded text-[9px] font-bold font-mono-data border uppercase tracking-widest shadow-sm"
|
||||
style={{
|
||||
backgroundColor: statusBg,
|
||||
color: statusColor,
|
||||
borderColor: `${statusColor}4d`,
|
||||
}}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Current Task */}
|
||||
<div
|
||||
className="p-4 rounded-xl bg-black/40 border shadow-inner"
|
||||
style={{ borderColor: "rgba(255,255,255,0.03)" }}
|
||||
>
|
||||
<p className="text-[9px] uppercase tracking-widest mb-2 text-zinc-500 font-mono-data">
|
||||
Latest Signal
|
||||
</p>
|
||||
<p className="text-[12px] font-medium tracking-wide text-zinc-300 line-clamp-2">
|
||||
{recentSignals[0]?.document || "No signals yet"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4 py-4 border-y border-white/5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[9px] uppercase tracking-widest text-zinc-500 font-mono-data">
|
||||
Total Signals
|
||||
</p>
|
||||
<p className="text-[13px] text-zinc-300 font-mono-data">{group.signal_count}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[9px] uppercase tracking-widest text-zinc-500 font-mono-data">
|
||||
High Priority
|
||||
</p>
|
||||
<p
|
||||
className="text-[13px] font-mono-data"
|
||||
style={{ color: criticalCount > 0 ? "#ff6f78" : "#10b981" }}
|
||||
>
|
||||
{criticalCount > 0 ? `${criticalCount} alerts` : "all clear"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Output - last 3 signals */}
|
||||
<div className="space-y-3 mt-auto flex-1 flex flex-col">
|
||||
<div className="flex justify-between items-center opacity-70">
|
||||
<p className="text-[9px] uppercase tracking-widest text-zinc-400 font-mono-data">
|
||||
Signal Stream
|
||||
</p>
|
||||
<span className="material-symbols-outlined text-[16px] text-zinc-500">terminal</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-[10px] p-4 rounded-xl bg-black/50 border overflow-y-auto font-mono-data shadow-inner flex-1 mt-2"
|
||||
style={{
|
||||
borderColor: "rgba(255,255,255,0.03)",
|
||||
color: "#9ca3af",
|
||||
minHeight: "5rem",
|
||||
maxHeight: "7rem",
|
||||
}}
|
||||
>
|
||||
{recentSignals.length === 0 ? (
|
||||
<p className="opacity-40">no signals yet_</p>
|
||||
) : (
|
||||
recentSignals.map((sig, idx) => (
|
||||
<p key={idx} className="mb-1.5 leading-relaxed">
|
||||
<span style={{ color: getSeverityColor(sig.metadata.severity), opacity: 0.9 }}>
|
||||
{sig.metadata.type}:
|
||||
</span>{" "}
|
||||
<span className="opacity-70 text-zinc-300">
|
||||
{sig.document.slice(0, 60)}
|
||||
{sig.document.length > 60 ? "…" : ""}
|
||||
</span>
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
<p className="animate-pulse mt-2 opacity-40">_</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentCards() {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [groupSignals, setGroupSignals] = useState<Record<string, Signal[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const grps = await fetchGroups();
|
||||
setGroups(grps);
|
||||
const sigMap: Record<string, Signal[]> = {};
|
||||
await Promise.all(
|
||||
grps.map(async (g) => {
|
||||
try {
|
||||
const sigs = await fetchSignals(g.group_id);
|
||||
sigMap[g.group_id] = sigs;
|
||||
} catch {
|
||||
sigMap[g.group_id] = [];
|
||||
}
|
||||
})
|
||||
);
|
||||
setGroupSignals(sigMap);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-zinc-600 col-span-3">
|
||||
<span className="material-symbols-outlined animate-spin mr-3">autorenew</span>
|
||||
Loading groups...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-zinc-600 col-span-3">
|
||||
<span className="material-symbols-outlined text-4xl mb-3 block">group_off</span>
|
||||
<p>No monitored groups yet.</p>
|
||||
<p className="text-[11px] mt-2">Connect Telegram groups to see agents here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in-up">
|
||||
{groups.map((group, idx) => (
|
||||
<GroupAgentCard
|
||||
key={group.group_id}
|
||||
group={group}
|
||||
signals={groupSignals[group.group_id] || []}
|
||||
index={idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
thirdeye/dashboard/app/agents/AgentStats.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchGroups, fetchAllSignals, Group, Signal } from "../lib/api";
|
||||
|
||||
export default function AgentStats() {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [signals, setSignals] = useState<Signal[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [grps, all] = await Promise.all([fetchGroups(), fetchAllSignals()]);
|
||||
setGroups(grps);
|
||||
setSignals(all.flatMap((g) => g.signals));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const totalSignals = groups.reduce((acc, g) => acc + g.signal_count, 0);
|
||||
const activeGroups = groups.filter((g) => g.signal_count > 0).length;
|
||||
const criticalSignals = signals.filter(
|
||||
(s) => s.metadata.severity === "critical" || s.metadata.severity === "high"
|
||||
).length;
|
||||
const errorRate =
|
||||
totalSignals > 0
|
||||
? `${((criticalSignals / totalSignals) * 100).toFixed(2)}%`
|
||||
: "0.00%";
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Active Groups",
|
||||
value: loading ? "—" : `${activeGroups} / ${groups.length}`,
|
||||
icon: "memory",
|
||||
iconColor: "#a88cfb",
|
||||
},
|
||||
{
|
||||
title: "Total Signals",
|
||||
value: loading ? "—" : totalSignals >= 1000 ? `${(totalSignals / 1000).toFixed(1)}k` : String(totalSignals),
|
||||
icon: "speed",
|
||||
iconColor: "#00daf3",
|
||||
},
|
||||
{
|
||||
title: "High Priority",
|
||||
value: loading ? "—" : `${criticalSignals}`,
|
||||
icon: "warning",
|
||||
iconColor: criticalSignals > 0 ? "#ff6f78" : "#10b981",
|
||||
},
|
||||
{
|
||||
title: "Lens Coverage",
|
||||
value: loading ? "—" : `${[...new Set(groups.map((g) => g.lens).filter(Boolean))].length} types`,
|
||||
icon: "verified",
|
||||
iconColor: "#10b981",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 animate-fade-in-scale mb-8">
|
||||
{stats.map((stat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="glass p-5 rounded-2xl border border-white/5 relative overflow-hidden group card-interactive flex flex-col justify-between"
|
||||
style={{
|
||||
minHeight: "100px",
|
||||
background: "rgba(20,20,25,0.4)",
|
||||
backdropFilter: "blur(12px)",
|
||||
}}
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-widest text-zinc-500 font-mono-data mb-3">
|
||||
{stat.title}
|
||||
</p>
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<h3 className="text-2xl font-light tracking-wide text-zinc-200 font-mono-data drop-shadow">
|
||||
{stat.value.split(" ").map((part, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
i % 2 !== 0 && part.match(/[a-zA-Z]/)
|
||||
? "text-sm ml-1 text-zinc-500"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{part}{" "}
|
||||
</span>
|
||||
))}
|
||||
</h3>
|
||||
<span
|
||||
className="material-symbols-outlined text-[24px] opacity-90 drop-shadow-md"
|
||||
style={{ color: stat.iconColor }}
|
||||
>
|
||||
{stat.icon}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
thirdeye/dashboard/app/agents/SystemTicker.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
export default function SystemTicker() {
|
||||
return (
|
||||
<div
|
||||
className="mt-12 p-3.5 rounded-lg overflow-hidden bg-black/20 border"
|
||||
style={{
|
||||
borderColor: "rgba(167, 139, 250, 0.05)",
|
||||
boxShadow: "inset 0 2px 10px rgba(0,0,0,0.1)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px] opacity-80"
|
||||
style={{ color: "#a88cfb" }}
|
||||
>
|
||||
pulse_alert
|
||||
</span>
|
||||
<div className="flex-1 overflow-hidden whitespace-nowrap">
|
||||
<div
|
||||
className="animate-marquee font-mono-data text-[10px] uppercase tracking-widest pl-[100%]"
|
||||
style={{ color: "#75757c" }}
|
||||
>
|
||||
SYSTEM_UPDATE: Node 14 synchronized. New encryption keys deployed. Agent
|
||||
'Graph_Builder_02' memory usage spike detected at 14:22:01. Global latency
|
||||
remains within 40ms threshold.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
thirdeye/dashboard/app/agents/agents.css
Normal file
@@ -0,0 +1,82 @@
|
||||
/* Agent fleet specific CSS */
|
||||
.glass-card {
|
||||
background: linear-gradient(180deg, rgba(28, 20, 45, 0.4) 0%, rgba(18, 14, 28, 0.6) 100%);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(167, 139, 250, 0.08);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(167, 139, 250, 0.5), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(167, 139, 250, 0.2);
|
||||
box-shadow: 0 10px 40px rgba(167, 139, 250, 0.15), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.glass-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.neon-glow-violet {
|
||||
box-shadow: 0 0 20px rgba(167, 139, 250, 0.15);
|
||||
}
|
||||
|
||||
.font-mono-data {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.terminal-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.terminal-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(167, 139, 250, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@keyframes agent-marquee {
|
||||
0% { transform: translateX(100%); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
display: inline-block;
|
||||
animation: agent-marquee 35s linear infinite;
|
||||
}
|
||||
|
||||
/* Terminal Line Entry Animation */
|
||||
.terminal-line {
|
||||
opacity: 0;
|
||||
animation: fade-in-up 0.5s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-line:nth-child(1) { animation-delay: 0.2s; }
|
||||
.terminal-line:nth-child(2) { animation-delay: 0.5s; }
|
||||
.terminal-line:nth-child(3) { animation-delay: 0.8s; }
|
||||
.terminal-line:nth-child(4) { animation-delay: 1.1s; }
|
||||
75
thirdeye/dashboard/app/agents/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import "./agents.css";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import TopBar from "../components/TopBar";
|
||||
import AgentStats from "./AgentStats";
|
||||
import AgentCards from "./AgentCards";
|
||||
import SystemTicker from "./SystemTicker";
|
||||
|
||||
export const metadata = {
|
||||
title: "ThirdEye | Agent Operations",
|
||||
description: "Agent Fleet Management — ThirdEye Sovereign Protocol",
|
||||
};
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[#09090B] text-white" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-[240px] flex flex-col h-screen overflow-hidden">
|
||||
<TopBar />
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar px-10 pb-12 pt-10">
|
||||
{/* Header & Stats */}
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-end mb-10 gap-6">
|
||||
<div className="space-y-1">
|
||||
<h1
|
||||
className="text-3xl font-black tracking-tighter text-white flex items-center gap-3"
|
||||
style={{ fontFamily: "'Inter Tight', sans-serif" }}
|
||||
>
|
||||
Fleet Management
|
||||
<span
|
||||
className="px-2 py-0.5 text-[10px] rounded border font-mono-data tracking-widest uppercase"
|
||||
style={{
|
||||
backgroundColor: "rgba(168, 140, 251, 0.1)",
|
||||
color: "#a88cfb",
|
||||
borderColor: "rgba(168, 140, 251, 0.2)",
|
||||
}}
|
||||
>
|
||||
Live Status
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
className="max-w-xl text-sm leading-relaxed"
|
||||
style={{ color: "#acaab1" }}
|
||||
>
|
||||
Active deployment of neural processing agents across ThirdEye node
|
||||
clusters. Monitoring real-time throughput and cognitive load.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="font-bold text-sm px-6 py-3 rounded-lg flex items-center gap-3 neon-glow-violet active:scale-95 transition-all"
|
||||
style={{
|
||||
backgroundColor: "#a88cfb",
|
||||
color: "#260069",
|
||||
fontFamily: "'Inter Tight', sans-serif",
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">add_circle</span>
|
||||
Deploy New Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<AgentStats />
|
||||
|
||||
{/* Agent Cards Grid */}
|
||||
<AgentCards />
|
||||
|
||||
{/* Ticker Section */}
|
||||
<SystemTicker />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
714
thirdeye/dashboard/app/chats/page.tsx
Normal file
@@ -0,0 +1,714 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import TopBar from "../components/TopBar";
|
||||
import {
|
||||
fetchTimeline,
|
||||
fetchGroups,
|
||||
raiseJiraTicket,
|
||||
TimelineSignal,
|
||||
Group,
|
||||
formatRelativeTime,
|
||||
getSeverityColor,
|
||||
getSignalIcon,
|
||||
parseMetaList,
|
||||
} from "../lib/api";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString("en-GB", {
|
||||
day: "2-digit", month: "short", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateGroup(iso: string): string {
|
||||
if (!iso) return "Unknown Date";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-GB", {
|
||||
weekday: "long", day: "numeric", month: "long", year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
function getDay(iso: string): string {
|
||||
try { return new Date(iso).toISOString().slice(0, 10); } catch { return ""; }
|
||||
}
|
||||
|
||||
const SEVERITY_OPTIONS = ["low", "medium", "high", "critical"];
|
||||
const LENS_OPTIONS = ["dev", "product", "client", "community", "meet"];
|
||||
const SIGNAL_TYPE_OPTIONS = [
|
||||
"architecture_decision", "tech_debt", "blocker", "risk", "decision",
|
||||
"action_item", "feature_request", "promise", "scope_creep", "knowledge_gap",
|
||||
"sentiment_spike", "recurring_bug", "meet_decision", "meet_action_item",
|
||||
"meet_blocker", "meet_risk", "meet_open_q",
|
||||
];
|
||||
|
||||
// ─── Signal Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SignalCard({
|
||||
signal,
|
||||
onRaiseJira,
|
||||
raisingId,
|
||||
raisedIds,
|
||||
}: {
|
||||
signal: TimelineSignal;
|
||||
onRaiseJira: (sig: TimelineSignal) => void;
|
||||
raisingId: string | null;
|
||||
raisedIds: Set<string>;
|
||||
}) {
|
||||
const meta = signal.metadata;
|
||||
const color = getSeverityColor(meta.severity);
|
||||
const icon = getSignalIcon(meta.type);
|
||||
const entities = parseMetaList(meta.entities);
|
||||
const keywords = parseMetaList(meta.keywords);
|
||||
const isRaised = raisedIds.has(signal.id);
|
||||
const isRaising = raisingId === signal.id;
|
||||
|
||||
const raiseable = [
|
||||
"tech_debt", "recurring_bug", "architecture_decision", "blocker", "risk",
|
||||
"feature_request", "priority_conflict", "promise", "scope_creep",
|
||||
"meet_action_item", "meet_blocker", "meet_risk", "meet_decision", "decision", "action_item",
|
||||
].includes(meta.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="neon-card-gradient rounded-xl border border-white/5 border-l-[3px] p-4 transition-all duration-200 hover:border-[rgba(167,139,250,0.2)] group"
|
||||
style={{ borderLeftColor: color }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="p-2 rounded-lg flex-shrink-0" style={{ background: `${color}18` }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "16px", color }}>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span
|
||||
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
|
||||
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
|
||||
>
|
||||
{meta.type.replace(/_/g, " ")}
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] font-bold uppercase tracking-[0.08em] px-2 py-0.5 rounded-full border border-white/10 text-zinc-500"
|
||||
>
|
||||
{meta.lens}
|
||||
</span>
|
||||
{signal.group_name && (
|
||||
<span className="text-[9px] text-zinc-600 font-mono-data">
|
||||
{signal.group_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[13px] text-zinc-200 leading-relaxed">
|
||||
{meta.summary || signal.document}
|
||||
</p>
|
||||
{meta.raw_quote && meta.raw_quote !== meta.summary && meta.type !== "meet_chunk_raw" && (
|
||||
<p className="text-[11px] text-zinc-500 mt-1.5 italic border-l-2 border-zinc-700 pl-2">
|
||||
"{meta.raw_quote.slice(0, 160)}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<span className="text-[10px] text-zinc-600 font-mono-data whitespace-nowrap">
|
||||
{formatRelativeTime(meta.timestamp)}
|
||||
</span>
|
||||
{raiseable && !isRaised && (
|
||||
<button
|
||||
onClick={() => onRaiseJira(signal)}
|
||||
disabled={isRaising}
|
||||
className="text-[9px] text-zinc-600 hover:text-[#A78BFA] transition-colors flex items-center gap-1 opacity-0 group-hover:opacity-100"
|
||||
title="Raise Jira ticket"
|
||||
>
|
||||
{isRaising ? (
|
||||
<div className="w-3 h-3 border border-[#A78BFA]/40 border-t-[#A78BFA] rounded-full animate-spin" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "13px" }}>bug_report</span>
|
||||
)}
|
||||
Raise Jira
|
||||
</button>
|
||||
)}
|
||||
{isRaised && (
|
||||
<span className="text-[9px] text-emerald-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "12px" }}>check_circle</span>
|
||||
Raised
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(entities.length > 0 || keywords.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{entities.slice(0, 4).map((e, i) => (
|
||||
<span key={i} className="text-[10px] text-[#A78BFA] bg-[rgba(167,139,250,0.08)] px-1.5 py-0.5 rounded">
|
||||
{e}
|
||||
</span>
|
||||
))}
|
||||
{keywords.slice(0, 4).map((k, i) => (
|
||||
<span key={i} className="text-[10px] text-zinc-600 bg-[rgba(255,255,255,0.04)] px-1.5 py-0.5 rounded">
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Timeline View ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TimelineView({
|
||||
signals,
|
||||
onRaiseJira,
|
||||
raisingId,
|
||||
raisedIds,
|
||||
}: {
|
||||
signals: TimelineSignal[];
|
||||
onRaiseJira: (sig: TimelineSignal) => void;
|
||||
raisingId: string | null;
|
||||
raisedIds: Set<string>;
|
||||
}) {
|
||||
// Group by day
|
||||
const byDay: Map<string, TimelineSignal[]> = new Map();
|
||||
for (const sig of signals) {
|
||||
const day = getDay(sig.metadata.timestamp);
|
||||
if (!byDay.has(day)) byDay.set(day, []);
|
||||
byDay.get(day)!.push(sig);
|
||||
}
|
||||
|
||||
if (signals.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
||||
<span className="material-symbols-outlined text-4xl mb-2">chat_bubble</span>
|
||||
<p className="text-[13px] uppercase tracking-wider">No signals found</p>
|
||||
<p className="text-[10px] text-zinc-700 mt-1">Signals appear as your Telegram groups generate activity</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{Array.from(byDay.entries()).map(([day, daySigs]) => (
|
||||
<div key={day}>
|
||||
{/* Day separator */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex-1 h-px bg-white/5" />
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 rounded-full border border-white/10 bg-[rgba(255,255,255,0.03)]">
|
||||
<span className="material-symbols-outlined text-zinc-500" style={{ fontSize: "13px" }}>calendar_today</span>
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider">
|
||||
{formatDateGroup(day)}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-700">·</span>
|
||||
<span className="text-[10px] text-zinc-600">{daySigs.length} signals</span>
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-white/5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{daySigs.map((sig) => (
|
||||
<SignalCard
|
||||
key={sig.id}
|
||||
signal={sig}
|
||||
onRaiseJira={onRaiseJira}
|
||||
raisingId={raisingId}
|
||||
raisedIds={raisedIds}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Table View ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TableView({
|
||||
signals,
|
||||
sortKey,
|
||||
sortAsc,
|
||||
onSort,
|
||||
onRaiseJira,
|
||||
raisingId,
|
||||
raisedIds,
|
||||
}: {
|
||||
signals: TimelineSignal[];
|
||||
sortKey: string;
|
||||
sortAsc: boolean;
|
||||
onSort: (key: string) => void;
|
||||
onRaiseJira: (sig: TimelineSignal) => void;
|
||||
raisingId: string | null;
|
||||
raisedIds: Set<string>;
|
||||
}) {
|
||||
const SortIcon = ({ k }: { k: string }) => (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "11px" }}>
|
||||
{sortKey !== k ? "unfold_more" : sortAsc ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (signals.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-zinc-600">
|
||||
<span className="material-symbols-outlined text-3xl mb-2">table_rows</span>
|
||||
<p className="text-[12px] uppercase tracking-wider">No signals</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 bg-[rgba(255,255,255,0.02)]">
|
||||
{[
|
||||
{ label: "Type", key: "type" },
|
||||
{ label: "Summary", key: null },
|
||||
{ label: "Severity", key: "severity" },
|
||||
{ label: "Lens", key: "lens" },
|
||||
{ label: "Group", key: "group_name" },
|
||||
{ label: "Time", key: "timestamp" },
|
||||
{ label: "", key: null },
|
||||
].map(({ label, key }) => (
|
||||
<th
|
||||
key={label}
|
||||
className={`px-4 py-3 text-left text-[10px] font-bold text-zinc-500 uppercase tracking-wider ${key ? "cursor-pointer hover:text-zinc-300" : ""}`}
|
||||
onClick={key ? () => onSort(key) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{label}
|
||||
{key && <SortIcon k={key} />}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.map((sig) => {
|
||||
const meta = sig.metadata;
|
||||
const color = getSeverityColor(meta.severity);
|
||||
const isRaised = raisedIds.has(sig.id);
|
||||
const isRaising = raisingId === sig.id;
|
||||
const raiseable = [
|
||||
"tech_debt", "recurring_bug", "architecture_decision", "blocker", "risk",
|
||||
"feature_request", "priority_conflict", "promise", "scope_creep",
|
||||
"meet_action_item", "meet_blocker", "meet_risk", "meet_decision", "decision", "action_item",
|
||||
].includes(meta.type);
|
||||
|
||||
return (
|
||||
<tr key={sig.id} className="border-b border-white/5 hover:bg-[rgba(167,139,250,0.04)] transition-colors group">
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
|
||||
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
|
||||
>
|
||||
{meta.type.replace(/_/g, " ")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-[280px]">
|
||||
<p className="text-[12px] text-zinc-200 truncate">{meta.summary || sig.document}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] font-semibold" style={{ color }}>{meta.severity}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">{meta.lens}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] text-zinc-500">{sig.group_name || meta.group_id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[10px] text-zinc-600 font-mono-data">{formatRelativeTime(meta.timestamp)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{raiseable && !isRaised && (
|
||||
<button
|
||||
onClick={() => onRaiseJira(sig)}
|
||||
disabled={isRaising}
|
||||
className="text-zinc-600 hover:text-[#A78BFA] transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Raise Jira ticket"
|
||||
>
|
||||
{isRaising ? (
|
||||
<div className="w-3.5 h-3.5 border border-[#A78BFA]/40 border-t-[#A78BFA] rounded-full animate-spin" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>bug_report</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isRaised && (
|
||||
<span className="material-symbols-outlined text-emerald-500" style={{ fontSize: "15px" }}>
|
||||
check_circle
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ChatsPage() {
|
||||
const [signals, setSignals] = useState<TimelineSignal[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<"timeline" | "table">("timeline");
|
||||
const [raisingId, setRaisingId] = useState<string | null>(null);
|
||||
const [raisedIds, setRaisedIds] = useState<Set<string>>(new Set());
|
||||
const [raiseMsg, setRaiseMsg] = useState("");
|
||||
|
||||
// Filters
|
||||
const [filterGroup, setFilterGroup] = useState("");
|
||||
const [filterSeverity, setFilterSeverity] = useState("");
|
||||
const [filterLens, setFilterLens] = useState("");
|
||||
const [filterType, setFilterType] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Sort (table view)
|
||||
const [sortKey, setSortKey] = useState("timestamp");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [result, grps] = await Promise.all([
|
||||
fetchTimeline({
|
||||
group_id: filterGroup || undefined,
|
||||
severity: filterSeverity || undefined,
|
||||
lens: filterLens || undefined,
|
||||
signal_type: filterType || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
limit: 300,
|
||||
}),
|
||||
fetchGroups(),
|
||||
]);
|
||||
setSignals(result.signals);
|
||||
setTotal(result.total);
|
||||
setTruncated(result.truncated);
|
||||
setGroups(grps);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterGroup, filterSeverity, filterLens, filterType, dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleRaiseJira = async (sig: TimelineSignal) => {
|
||||
setRaisingId(sig.id);
|
||||
try {
|
||||
const result = await raiseJiraTicket(sig.id, sig.metadata.group_id);
|
||||
if (result.ok && result.key) {
|
||||
setRaisedIds((prev) => new Set([...prev, sig.id]));
|
||||
setRaiseMsg(`Ticket ${result.key} raised!`);
|
||||
setTimeout(() => setRaiseMsg(""), 4000);
|
||||
} else {
|
||||
setRaiseMsg(result.reason === "already_raised" ? "Already raised" : (result.error || "Failed to raise ticket"));
|
||||
setTimeout(() => setRaiseMsg(""), 4000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setRaisingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Client-side search filter
|
||||
const filteredSignals = signals.filter((s) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
const meta = s.metadata;
|
||||
return (
|
||||
(meta.summary || "").toLowerCase().includes(q) ||
|
||||
(meta.type || "").toLowerCase().includes(q) ||
|
||||
(meta.raw_quote || "").toLowerCase().includes(q) ||
|
||||
(s.group_name || "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
// Sort for table view
|
||||
const sortedSignals = [...filteredSignals].sort((a, b) => {
|
||||
const metaA = a.metadata, metaB = b.metadata;
|
||||
let cmp = 0;
|
||||
if (sortKey === "timestamp") cmp = metaA.timestamp.localeCompare(metaB.timestamp);
|
||||
else if (sortKey === "severity") {
|
||||
const rank = { critical: 4, high: 3, medium: 2, low: 1 } as Record<string, number>;
|
||||
cmp = (rank[metaA.severity] || 0) - (rank[metaB.severity] || 0);
|
||||
} else if (sortKey === "type") cmp = metaA.type.localeCompare(metaB.type);
|
||||
else if (sortKey === "lens") cmp = metaA.lens.localeCompare(metaB.lens);
|
||||
else if (sortKey === "group_name") cmp = (a.group_name || "").localeCompare(b.group_name || "");
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const toggleSort = (key: string) => {
|
||||
if (sortKey === key) setSortAsc((a) => !a);
|
||||
else { setSortKey(key); setSortAsc(false); }
|
||||
};
|
||||
|
||||
const exportCSV = () => {
|
||||
const header = ["Type", "Summary", "Severity", "Lens", "Group", "Timestamp"].join(",");
|
||||
const rows = filteredSignals.map((s) => {
|
||||
const m = s.metadata;
|
||||
return [
|
||||
m.type,
|
||||
`"${(m.summary || "").replace(/"/g, '""').slice(0, 200)}"`,
|
||||
m.severity,
|
||||
m.lens,
|
||||
s.group_name || m.group_id,
|
||||
m.timestamp,
|
||||
].join(",");
|
||||
});
|
||||
const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = "chat_signals.csv"; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const activeFilters = [filterGroup, filterSeverity, filterLens, filterType, dateFrom, dateTo, search].filter(Boolean).length;
|
||||
|
||||
const criticalCount = signals.filter(s => s.metadata.severity === "critical").length;
|
||||
const highCount = signals.filter(s => s.metadata.severity === "high").length;
|
||||
const uniqueGroups = new Set(signals.map(s => s.metadata.group_id)).size;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex" style={{ backgroundColor: "#09090B" }}>
|
||||
<Sidebar />
|
||||
<div className="flex-1 ml-[240px] flex flex-col min-h-screen">
|
||||
<TopBar />
|
||||
<main className="flex-1 p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-8 animate-fade-in-up opacity-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-[rgba(167,139,250,0.15)] flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-[#A78BFA]">chat</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white tracking-tight">Chat History</h1>
|
||||
<p className="text-[11px] text-zinc-500 uppercase tracking-[0.2em]">
|
||||
Signal timeline · Cross-group intelligence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{raiseMsg && (
|
||||
<span className="text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-lg animate-fade-in-scale opacity-0">
|
||||
{raiseMsg}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={exportCSV}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 text-[11px] text-zinc-400 hover:text-zinc-200 hover:border-white/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>download</span>
|
||||
Export CSV
|
||||
</button>
|
||||
<div className="flex bg-[#141419] border border-white/10 rounded-lg p-0.5">
|
||||
{(["timeline", "table"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-semibold uppercase tracking-wider transition-all ${
|
||||
viewMode === mode
|
||||
? "bg-[#A78BFA] text-[#1a0040]"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>
|
||||
{mode === "timeline" ? "view_timeline" : "table_rows"}
|
||||
</span>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6 animate-fade-in-up opacity-0 delay-100">
|
||||
{[
|
||||
{ label: "Total Signals", value: total, icon: "sensors", color: "#A78BFA" },
|
||||
{ label: "Critical", value: criticalCount, icon: "error", color: "#EF4444" },
|
||||
{ label: "High Priority", value: highCount, icon: "warning", color: "#F97316" },
|
||||
{ label: "Active Groups", value: uniqueGroups, icon: "group", color: "#60A5FA" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="neon-card-gradient rounded-xl p-4 border border-white/5 flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ background: `${s.color}18` }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "18px", color: s.color }}>{s.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{s.value}</p>
|
||||
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">{s.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="neon-card-gradient rounded-xl border border-white/5 p-4 mb-5 animate-fade-in-up opacity-0 delay-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-zinc-500" style={{ fontSize: "16px" }}>filter_alt</span>
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider">Filters</span>
|
||||
{activeFilters > 0 && (
|
||||
<span className="text-[9px] font-bold text-[#A78BFA] bg-[rgba(167,139,250,0.15)] px-2 py-0.5 rounded-full">
|
||||
{activeFilters} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{activeFilters > 0 && (
|
||||
<button
|
||||
onClick={() => { setFilterGroup(""); setFilterSeverity(""); setFilterLens(""); setFilterType(""); setDateFrom(""); setDateTo(""); setSearch(""); }}
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300 uppercase tracking-wider flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "13px" }}>filter_alt_off</span>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" style={{ fontSize: "14px" }}>search</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search signals..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="bg-[#0C0C0E] border border-white/10 rounded-lg pl-8 pr-3 py-2 text-[12px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50 w-44"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group */}
|
||||
<select
|
||||
value={filterGroup}
|
||||
onChange={(e) => setFilterGroup(e.target.value)}
|
||||
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
>
|
||||
<option value="">All Groups</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.group_id} value={g.group_id}>{g.group_name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Severity */}
|
||||
<select
|
||||
value={filterSeverity}
|
||||
onChange={(e) => setFilterSeverity(e.target.value)}
|
||||
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
{SEVERITY_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Lens */}
|
||||
<select
|
||||
value={filterLens}
|
||||
onChange={(e) => setFilterLens(e.target.value)}
|
||||
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
>
|
||||
<option value="">All Lenses</option>
|
||||
{LENS_OPTIONS.map((l) => (
|
||||
<option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Signal type */}
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{SIGNAL_TYPE_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>{t.replace(/_/g, " ")}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom ? dateFrom.slice(0, 10) : ""}
|
||||
onChange={(e) => setDateFrom(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
||||
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-2 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
/>
|
||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo ? dateTo.slice(0, 10) : ""}
|
||||
onChange={(e) => setDateTo(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
||||
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-2 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Count line */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-[11px] text-zinc-600">
|
||||
Showing <span className="text-zinc-400 font-semibold">{filteredSignals.length}</span>
|
||||
{total > filteredSignals.length && ` of ${total}`} signals
|
||||
{truncated && <span className="text-amber-500 ml-2">· Showing latest 300 — apply filters to narrow down</span>}
|
||||
</p>
|
||||
<button onClick={load} className="flex items-center gap-1 text-[11px] text-zinc-500 hover:text-[#A78BFA] transition-colors">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>refresh</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
||||
<div className="w-8 h-8 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin mb-3" />
|
||||
<p className="text-[11px] uppercase tracking-wider">Loading signals...</p>
|
||||
</div>
|
||||
) : viewMode === "timeline" ? (
|
||||
<TimelineView
|
||||
signals={filteredSignals}
|
||||
onRaiseJira={handleRaiseJira}
|
||||
raisingId={raisingId}
|
||||
raisedIds={raisedIds}
|
||||
/>
|
||||
) : (
|
||||
<div className="neon-card-gradient rounded-2xl border border-white/5 overflow-hidden">
|
||||
<TableView
|
||||
signals={sortedSignals}
|
||||
sortKey={sortKey}
|
||||
sortAsc={sortAsc}
|
||||
onSort={toggleSort}
|
||||
onRaiseJira={handleRaiseJira}
|
||||
raisingId={raisingId}
|
||||
raisedIds={raisedIds}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
thirdeye/dashboard/app/components/InsightHero.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
export default function InsightHero() {
|
||||
return (
|
||||
<div className="glass rounded-2xl border border-white/5 p-8 relative overflow-hidden group animate-fade-in-scale delay-200">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10 blur-xl group-hover:opacity-20 transition-opacity duration-700">
|
||||
{/* This div seems to be a placeholder or an incomplete instruction.
|
||||
The content '8BFA", boxShadow: "0 0 15px rgba(167, 139, 250, 0.08)", }'
|
||||
is not valid JSX content. Assuming it was meant to be part of a style
|
||||
or a class, but without clear instruction, it's left as an empty div
|
||||
to maintain syntactic correctness. */}
|
||||
</div>
|
||||
<div className="p-10 grid md:grid-cols-3 gap-10 relative z-10">
|
||||
{/* Left: main content */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="px-3 py-1 text-[10px] font-bold tracking-[0.1em] rounded-full"
|
||||
style={{
|
||||
color: "#A78BFA",
|
||||
backgroundColor: "rgba(167,139,250,0.2)",
|
||||
border: "1px solid rgba(167,139,250,0.3)",
|
||||
}}
|
||||
>
|
||||
SYSTEM_ALERT
|
||||
</span>
|
||||
<span className="text-zinc-400 text-[11px] uppercase tracking-widest font-semibold">
|
||||
Cross-Group Intelligence Analysis
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white leading-tight">
|
||||
Discrepancy in Strategy Alignment: Product vs Engineering
|
||||
</h2>
|
||||
|
||||
<p className="text-zinc-300 text-[15px] leading-relaxed max-w-2xl font-light">
|
||||
Insight explanation: Discrepancy in Q3 roadmap priorities identified across 4
|
||||
cross-functional meetings. Product prioritizes feature velocity, while Engineering
|
||||
focuses on technical debt reduction.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button className="bg-[#A78BFA]/20 text-[#A78BFA] border border-[#A78BFA]/50 hover:bg-[#A78BFA] hover:text-[#09090B] px-6 py-2 rounded-lg text-sm font-semibold tracking-widest uppercase btn-interactive shadow-[0_0_15px_rgba(167,139,250,0.1)] hover:shadow-[0_0_20px_rgba(167,139,250,0.4)]">
|
||||
Schedule Sync
|
||||
</button>
|
||||
<button className="text-zinc-400 hover:text-zinc-200 bg-white/5 hover:bg-white/10 px-6 py-2 rounded-lg text-sm font-semibold tracking-widest uppercase transition-colors btn-interactive">
|
||||
Dismiss Signal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: alignment bars */}
|
||||
<div className="space-y-6">
|
||||
{/* Group A */}
|
||||
<div
|
||||
className="p-5 rounded-xl border"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.2)" }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-[11px] text-zinc-300 font-semibold">Group A (Product)</span>
|
||||
<span className="text-xs text-white">72%</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-1.5 rounded-full overflow-hidden"
|
||||
style={{ backgroundColor: "rgba(255,255,255,0.05)" }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: "72%", backgroundColor: "rgba(167,139,250,0.6)" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400 mt-3 italic font-light tracking-wide">
|
||||
Alignment on Speed-to-Market
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Group B */}
|
||||
<div
|
||||
className="p-5 rounded-xl border"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.2)" }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-[11px] text-zinc-300 font-semibold">Group B (Eng)</span>
|
||||
<span className="text-xs text-white">84%</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-1.5 rounded-full overflow-hidden"
|
||||
style={{ backgroundColor: "rgba(255,255,255,0.05)" }}
|
||||
>
|
||||
<div className="h-full rounded-full" style={{ width: "84%", backgroundColor: "#A78BFA" }} />
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400 mt-3 italic font-light tracking-wide">
|
||||
Focus on System Stability
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recommendation */}
|
||||
<div
|
||||
className="p-5 rounded-xl"
|
||||
style={{
|
||||
border: "1px solid rgba(167,139,250,0.2)",
|
||||
backgroundColor: "rgba(167,139,250,0.1)",
|
||||
}}
|
||||
>
|
||||
<p className="text-[12px] font-medium leading-relaxed" style={{ color: "#A78BFA" }}>
|
||||
<span className="font-bold">RECOMMENDATION:</span> Schedule immediate alignment session
|
||||
for Q3 priority sync.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abstract gradient element */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-96 pointer-events-none"
|
||||
style={{
|
||||
opacity: 0.1,
|
||||
background: "linear-gradient(to left, #A78BFA, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
thirdeye/dashboard/app/components/IntelligenceTicker.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
const logs = [
|
||||
"[Signal_Rcv] :: Offset=0x3f4 :: Status=Stable",
|
||||
"[Agent_Sync] :: UUID_4492 :: Auth_Granted",
|
||||
"[IO_Wait] :: 0.042ms :: Vector_Validated",
|
||||
"[Core_Idle] :: Temp_Optimal",
|
||||
"[Log_Dump] :: Stream_Active",
|
||||
];
|
||||
|
||||
export default function IntelligenceTicker() {
|
||||
// Duplicate for seamless looping
|
||||
const allLogs = [...logs, ...logs];
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="rounded-xl border overflow-hidden"
|
||||
style={{ backgroundColor: "#0C0C0E", padding: "1rem" }}
|
||||
>
|
||||
<div className="flex items-center gap-6 overflow-hidden">
|
||||
<span
|
||||
className="text-[10px] font-extrabold uppercase tracking-widest rounded-full px-3 py-1 flex-shrink-0"
|
||||
style={{
|
||||
color: "#A78BFA",
|
||||
backgroundColor: "rgba(167,139,250,0.1)",
|
||||
border: "1px solid rgba(167,139,250,0.2)",
|
||||
}}
|
||||
>
|
||||
System_Log
|
||||
</span>
|
||||
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="ticker-track">
|
||||
{allLogs.map((log, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] font-medium text-zinc-600 uppercase tracking-wide opacity-80 mr-12 whitespace-nowrap"
|
||||
>
|
||||
{log}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
85
thirdeye/dashboard/app/components/LiveSignals.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
const signals = [
|
||||
{
|
||||
icon: "warning",
|
||||
time: "T-2m ago",
|
||||
message:
|
||||
"Unusual spike in security mentions in DevOps channels. Analyzing 12 recent signals...",
|
||||
tag: "Security Risk",
|
||||
borderOpacity: "border-l-[3px]",
|
||||
borderColor: "rgba(167,139,250,0.3)",
|
||||
},
|
||||
{
|
||||
icon: "check_circle",
|
||||
time: "T-14m ago",
|
||||
message:
|
||||
"High consensus reached on Microservices architecture for project 'Aurora'. 95% alignment.",
|
||||
tag: "Consensus Met",
|
||||
borderOpacity: "border-l-[3px]",
|
||||
borderColor: "#A78BFA",
|
||||
},
|
||||
{
|
||||
icon: "campaign",
|
||||
time: "T-1h ago",
|
||||
message:
|
||||
"New 'ThirdEye Sovereign' feature mentioned across all leadership streams. Tracking adoption...",
|
||||
tag: "Brand Sync",
|
||||
borderOpacity: "border-l-[3px]",
|
||||
borderColor: "rgba(167,139,250,0.5)",
|
||||
},
|
||||
{
|
||||
icon: "psychology_alt",
|
||||
time: "T-2h ago",
|
||||
message:
|
||||
"Ongoing trend: Focus shift to AI-assisted development across 5 engineering squads.",
|
||||
tag: "Trend Vector",
|
||||
borderOpacity: "border-l-[3px]",
|
||||
borderColor: "rgba(167,139,250,0.7)",
|
||||
},
|
||||
];
|
||||
|
||||
export default function LiveSignals() {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4 animate-fade-in-right delay-300">
|
||||
<div className="w-2 h-2 rounded-full bg-[#A78BFA] shadow-[0_0_10px_#A78BFA] animate-pulse"></div>
|
||||
<h3 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">Live Signals Stream</h3>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button className="text-zinc-500 hover:text-[#A78BFA] transition-colors btn-interactive"><span className="material-symbols-outlined text-sm">filter_list</span></button>
|
||||
<button className="text-zinc-500 hover:text-[#A78BFA] transition-colors btn-interactive"><span className="material-symbols-outlined text-sm">sort</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 animate-fade-in-up delay-400">
|
||||
{signals.map((s, idx) => (
|
||||
<div key={idx} className="glass rounded-xl p-4 border border-white/5 hover:border-[#A78BFA]/30 hover:bg-white/5 transition-all group card-interactive flex flex-col justify-between min-h-[140px]" style={{ animationDelay: `${400 + idx * 100}ms` }}>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div
|
||||
className="p-2.5 rounded-lg"
|
||||
style={{ backgroundColor: "rgba(167,139,250,0.1)" }}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ color: "#A78BFA", fontSize: "22px" }}>
|
||||
{s.icon}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-400 uppercase tracking-tighter">{s.time}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[14px] font-medium text-zinc-200 leading-relaxed mb-8">
|
||||
{s.message}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.1em]" style={{ color: "#A78BFA" }}>
|
||||
{s.tag}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-zinc-500 group-hover:text-[#A78BFA] transition-all cursor-pointer" style={{ fontSize: "22px" }}>
|
||||
arrow_forward
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
18
thirdeye/dashboard/app/components/MaterialSymbols.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function MaterialSymbols() {
|
||||
useEffect(() => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap';
|
||||
document.head.appendChild(link);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(link);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
62
thirdeye/dashboard/app/components/MetricTiles.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
const metrics = [
|
||||
{
|
||||
label: "Active Agents",
|
||||
icon: "sensors",
|
||||
value: "14",
|
||||
sub: "+2 Synced",
|
||||
subColor: "text-[#A78BFA]",
|
||||
subIcon: "trending_up",
|
||||
},
|
||||
{
|
||||
label: "Signals Processed",
|
||||
icon: "data_exploration",
|
||||
value: "8.4k",
|
||||
sub: "Last 24h Cycle",
|
||||
subColor: "text-zinc-400",
|
||||
subIcon: null,
|
||||
},
|
||||
{
|
||||
label: "Open Insights",
|
||||
icon: "lightbulb",
|
||||
value: "23",
|
||||
sub: "4 Critical Priority",
|
||||
subColor: "text-[#A78BFA] font-semibold uppercase tracking-tighter",
|
||||
subIcon: null,
|
||||
},
|
||||
{
|
||||
label: "Avg Latency",
|
||||
icon: "timer",
|
||||
value: "184ms",
|
||||
sub: "Optimal Range",
|
||||
subColor: "text-zinc-400 uppercase tracking-tighter",
|
||||
subIcon: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default function MetricTiles() {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-6 animate-fade-in-up">
|
||||
{metrics.map((metric, index) => (
|
||||
<div key={index} className={`glass p-6 rounded-2xl border flex flex-col justify-between relative overflow-hidden group card-interactive cursor-pointer`} style={{ borderColor: 'rgba(255, 255, 255, 0.05)', animationDelay: `${index * 100}ms` }}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.15em] font-semibold text-zinc-400">
|
||||
{metric.label}
|
||||
</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "18px", color: "rgba(167,139,250,0.6)" }}>
|
||||
{metric.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold text-white">{metric.value}</div>
|
||||
<div className={`text-[11px] mt-3 flex items-center gap-1.5 font-medium ${metric.subColor}`}>
|
||||
{metric.subIcon && (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "11px" }}>
|
||||
{metric.subIcon}
|
||||
</span>
|
||||
)}
|
||||
<span>{metric.sub}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
thirdeye/dashboard/app/components/Sidebar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const navItems = [
|
||||
{ icon: "target", label: "Mission", href: "/mission" },
|
||||
{ icon: "group", label: "Agents", href: "/agents" },
|
||||
{ icon: "psychology", label: "Intelligence", href: "/intelligence" },
|
||||
{ icon: "hub", label: "Knowledge Base", href: "/knowledge-base" },
|
||||
{ icon: "terminal", label: "System Logs", href: "/logs" },
|
||||
];
|
||||
|
||||
const activityItems = [
|
||||
{ icon: "video_camera_front", label: "Meetings", href: "/meetings" },
|
||||
{ icon: "bug_report", label: "Jira Tickets", href: "/jira" },
|
||||
{ icon: "chat", label: "Chat History", href: "/chats" },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="h-screen w-[240px] fixed left-0 top-0 flex flex-col border-r z-50"
|
||||
style={{ backgroundColor: "#0C0C0E" }}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="p-8">
|
||||
<Link href="/" className="flex items-center gap-3 w-full hover:opacity-80 transition-opacity drop-shadow-lg">
|
||||
<Image src="/new-logo.png" alt="ThirdEye" width={32} height={32} className="rounded-md" />
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="text-2xl font-black tracking-wide text-[#a88cf8] uppercase leading-none mt-1">THIRDEYE</div>
|
||||
<div className="text-[9px] font-bold tracking-[0.3em] text-zinc-500 mt-1.5 uppercase">SOVEREIGN_V1</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 space-y-1 overflow-y-auto custom-scrollbar">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-300 ${
|
||||
isActive
|
||||
? "text-[#A78BFA] bg-[rgba(167,139,250,0.1)]"
|
||||
: "text-zinc-500 hover:text-zinc-200 hover:bg-[#141419]"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-[11px] uppercase tracking-wider font-semibold">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Activity section */}
|
||||
<div className="pt-4 pb-1 px-4">
|
||||
<p className="text-[9px] font-bold tracking-[0.25em] text-zinc-600 uppercase">Activity</p>
|
||||
</div>
|
||||
{activityItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-300 ${
|
||||
isActive
|
||||
? "text-[#A78BFA] bg-[rgba(167,139,250,0.1)]"
|
||||
: "text-zinc-500 hover:text-zinc-200 hover:bg-[#141419]"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-[11px] uppercase tracking-wider font-semibold">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t">
|
||||
<Link
|
||||
href="#"
|
||||
className="flex items-center gap-3 px-4 py-3 text-zinc-500 hover:text-zinc-200 hover:bg-[#141419] rounded-lg transition-all duration-300 mb-6"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
|
||||
settings
|
||||
</span>
|
||||
<span className="text-[11px] uppercase tracking-wider font-semibold">
|
||||
Settings
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* User Card */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl"
|
||||
style={{ backgroundColor: "rgba(20,20,25,0.5)" }}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded flex items-center justify-center overflow-hidden flex-shrink-0"
|
||||
style={{ backgroundColor: "rgba(167,139,250,0.1)" }}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[#A78BFA]"
|
||||
style={{ fontSize: "18px" }}
|
||||
>
|
||||
person
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-[11px] text-zinc-200 font-semibold truncate">CMD_DECKARD</p>
|
||||
<p className="text-[9px] text-zinc-500 uppercase tracking-tighter">Clearance L4</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
82
thirdeye/dashboard/app/components/TopBar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { checkHealth } from "../lib/api";
|
||||
|
||||
export default function TopBar() {
|
||||
const [healthy, setHealthy] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth().then(setHealthy);
|
||||
const interval = setInterval(() => checkHealth().then(setHealthy), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="h-20 flex justify-between items-center px-10 w-full border-b z-40 sticky top-0"
|
||||
style={{ backgroundColor: "rgba(12,12,14,0.8)", backdropFilter: "blur(24px)" }}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
<span
|
||||
className="material-symbols-outlined absolute left-0 top-1/2 -translate-y-1/2 text-zinc-600"
|
||||
style={{ fontSize: "18px" }}
|
||||
>
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Query the system..."
|
||||
className="bg-transparent border-none text-[13px] focus:outline-none rounded py-2 pl-8 pr-4 w-72 placeholder:text-zinc-700 text-zinc-200 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-center gap-6 text-zinc-500">
|
||||
<span
|
||||
className="material-symbols-outlined text-lg hover:text-[#A78BFA] cursor-pointer transition-colors"
|
||||
style={{ fontSize: "22px" }}
|
||||
>
|
||||
notifications
|
||||
</span>
|
||||
<span
|
||||
className="material-symbols-outlined text-lg hover:text-[#A78BFA] cursor-pointer transition-colors"
|
||||
style={{ fontSize: "22px" }}
|
||||
>
|
||||
terminal
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px" style={{ backgroundColor: "rgba(255,255,255,0.05)" }} />
|
||||
|
||||
{/* Health indicator */}
|
||||
<div
|
||||
className="text-[10px] font-bold px-4 py-1.5 rounded-full tracking-wide flex items-center gap-2"
|
||||
style={{
|
||||
color: healthy === false ? "#ff6f78" : "#A78BFA",
|
||||
backgroundColor: healthy === false ? "rgba(255,111,120,0.05)" : "rgba(167,139,250,0.05)",
|
||||
border: `1px solid ${healthy === false ? "rgba(255,111,120,0.2)" : "rgba(167,139,250,0.2)"}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: healthy === null ? "#A78BFA" : healthy ? "#10b981" : "#ff6f78",
|
||||
display: "inline-block",
|
||||
boxShadow: healthy ? "0 0 6px #10b981" : undefined,
|
||||
}}
|
||||
/>
|
||||
{healthy === null
|
||||
? "CONNECTING..."
|
||||
: healthy
|
||||
? "THIRDEYE_CORE: ONLINE"
|
||||
: "BACKEND: OFFLINE"}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
BIN
thirdeye/dashboard/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
469
thirdeye/dashboard/app/globals.css
Normal file
@@ -0,0 +1,469 @@
|
||||
/* ── Google Fonts ─────────────────────────────────────────────────────────── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800;900&family=Inter+Tight:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-primary: #cebdff;
|
||||
--color-primary-container: #a78bfa;
|
||||
--color-on-primary-container: #260069;
|
||||
--color-on-primary: #381385;
|
||||
--color-on-primary-fixed: #21005e;
|
||||
--color-primary-fixed: #e8ddff;
|
||||
--color-primary-fixed-dim: #cebdff;
|
||||
--color-on-primary-fixed-variant: #4f319c;
|
||||
|
||||
--color-secondary: #b0c6ff;
|
||||
--color-secondary-container: #0558c8;
|
||||
--color-on-secondary: #002d6e;
|
||||
--color-on-secondary-container: #cbd8ff;
|
||||
--color-secondary-fixed: #d9e2ff;
|
||||
--color-secondary-fixed-dim: #b0c6ff;
|
||||
--color-on-secondary-fixed: #001945;
|
||||
--color-on-secondary-fixed-variant: #00429b;
|
||||
|
||||
--color-tertiary: #ffb2be;
|
||||
--color-tertiary-container: #ff6b8c;
|
||||
--color-on-tertiary: #660025;
|
||||
--color-on-tertiary-container: #6e0029;
|
||||
--color-tertiary-fixed: #ffd9de;
|
||||
--color-tertiary-fixed-dim: #ffb2be;
|
||||
--color-on-tertiary-fixed: #400014;
|
||||
--color-on-tertiary-fixed-variant: #900038;
|
||||
|
||||
--color-background: #09090B;
|
||||
--color-on-background: #e5e1e4;
|
||||
|
||||
--color-surface: #131315;
|
||||
--color-surface-dim: #131315;
|
||||
--color-surface-bright: #39393b;
|
||||
--color-surface-container-lowest: #0e0e10;
|
||||
--color-surface-container-low: #1c1b1d;
|
||||
--color-surface-container: #201f22;
|
||||
--color-surface-container-high: #2a2a2c;
|
||||
--color-surface-container-highest: #353437;
|
||||
--color-surface-variant: #353437;
|
||||
--color-on-surface: #e5e1e4;
|
||||
--color-on-surface-variant: #cac4d4;
|
||||
--color-surface-tint: #cebdff;
|
||||
|
||||
--color-inverse-surface: #e5e1e4;
|
||||
--color-inverse-on-surface: #313032;
|
||||
--color-inverse-primary: #674bb5;
|
||||
|
||||
--color-outline: #948e9d;
|
||||
--color-outline-variant: #494552;
|
||||
|
||||
--color-error: #ffb4ab;
|
||||
--color-error-container: #93000a;
|
||||
--color-on-error: #690005;
|
||||
--color-on-error-container: #ffdad6;
|
||||
|
||||
/* Font Families */
|
||||
--font-poppins: 'Poppins', sans-serif;
|
||||
--font-inter-tight: 'Inter Tight', sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #09090B;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Material Symbols ────────────────────────────────────────────────────── */
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
font-feature-settings: 'liga';
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
|
||||
/* ── Typography ──────────────────────────────────────────────────────────── */
|
||||
.font-mono-data {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* ── Glass Card ──────────────────────────────────────────────────────────── */
|
||||
.glass {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(28, 20, 45, 0.5) 0%,
|
||||
rgba(18, 14, 28, 0.7) 100%);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* ── Neon Card Gradient ──────────────────────────────────────────────────── */
|
||||
.neon-card-gradient {
|
||||
background: linear-gradient(160deg,
|
||||
rgba(22, 16, 36, 0.8) 0%,
|
||||
rgba(12, 12, 14, 0.95) 100%);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* ── Primary Glow ────────────────────────────────────────────────────────── */
|
||||
.primary-glow {
|
||||
box-shadow: 0 0 30px rgba(167, 139, 250, 0.15),
|
||||
0 0 60px rgba(167, 139, 250, 0.05);
|
||||
}
|
||||
|
||||
/* ── Interactive Elements ────────────────────────────────────────────────── */
|
||||
.card-interactive {
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 40px rgba(167, 139, 250, 0.12),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.btn-interactive {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.btn-interactive:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-interactive:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* ── Custom Scrollbar ────────────────────────────────────────────────────── */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(167, 139, 250, 0.25);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(167, 139, 250, 0.45);
|
||||
}
|
||||
|
||||
/* ── Ticker Track (Scrolling marquee) ────────────────────────────────────── */
|
||||
.ticker-track {
|
||||
display: inline-flex;
|
||||
gap: 3rem;
|
||||
white-space: nowrap;
|
||||
animation: ticker-scroll 35s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ticker-scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Animations ──────────────────────────────────────────────────────────── */
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-scale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-scale {
|
||||
animation: fade-in-scale 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-right {
|
||||
animation: fade-in-right 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
/* Delay utilities */
|
||||
.delay-100 {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.delay-150 {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
.delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.delay-300 {
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
.delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
.delay-500 {
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
|
||||
/* ── Neon Glow Utilities ─────────────────────────────────────────────────── */
|
||||
.neon-glow-violet {
|
||||
box-shadow: 0 0 20px rgba(167, 139, 250, 0.2),
|
||||
0 0 40px rgba(167, 139, 250, 0.08);
|
||||
}
|
||||
|
||||
/* ── Active Pulse ────────────────────────────────────────────────────────── */
|
||||
.active-pulse {
|
||||
box-shadow: 0 0 10px rgba(167, 139, 250, 0.5);
|
||||
animation: active-pulse-anim 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes active-pulse-anim {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 10px rgba(167, 139, 250, 0.5);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
box-shadow: 0 0 20px rgba(167, 139, 250, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Landing Page Styles ─────────────────────────────────────────────────── */
|
||||
.kinetic-grid {
|
||||
background-image: radial-gradient(circle at 2px 2px, rgba(167, 139, 250, 0.08) 1px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.glow-violet {
|
||||
box-shadow: 0 0 60px -15px rgba(167, 139, 250, 0.45);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: linear-gradient(135deg, rgba(167, 139, 250, 0.08), rgba(32, 31, 34, 0.6));
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.glass-border {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.glass-border::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(167, 139, 250, 0.4), rgba(167, 139, 250, 0.05) 40%, rgba(167, 139, 250, 0.05) 60%, rgba(139, 92, 246, 0.3));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
filter: blur(120px);
|
||||
transform: translate(-50%, -10%) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
filter: blur(160px);
|
||||
transform: translate(-50%, -5%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.alive-glow {
|
||||
animation: pulse-glow 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hero-title-gradient {
|
||||
background: linear-gradient(to bottom right, #fff 30%, #cebdff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ── Glowing Tech Background ─────────────────────────────────────────────── */
|
||||
.tech-bg-base {
|
||||
background-color: #09090B;
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 50%, rgba(167, 139, 250, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(circle at 85% 30%, rgba(99, 102, 241, 0.05) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.tech-grid {
|
||||
background-size: 50px 50px;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
||||
animation: grid-drift 40s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes grid-drift {
|
||||
0% { background-position: 0px 0px; }
|
||||
100% { background-position: -50px -50px; }
|
||||
}
|
||||
|
||||
.tech-lights {
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0%, rgba(167, 139, 250, 0.08) 50%, transparent 100%),
|
||||
linear-gradient(0deg, transparent 0%, rgba(167, 139, 250, 0.04) 50%, transparent 100%);
|
||||
background-size: 100% 4px, 4px 100%;
|
||||
animation: scan-lines 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scan-lines {
|
||||
0% { background-position: 0 0, 0 0; }
|
||||
100% { background-position: 0 100px, 100px 0; }
|
||||
}
|
||||
|
||||
/* ── Black Hole Glow Effect ───────────────────────────────────────────── */
|
||||
.black-hole-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 16px solid #fff;
|
||||
background: #000;
|
||||
box-shadow:
|
||||
0 0 40px 10px #fff,
|
||||
0 0 80px 30px #d8b4fe,
|
||||
0 0 160px 60px #a855f7,
|
||||
0 0 250px 100px #7e22ce,
|
||||
inset 0 0 20px 5px #fff,
|
||||
inset 0 0 40px 10px #d8b4fe;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hero-beam {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 140vw;
|
||||
height: 8px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
filter: blur(10px);
|
||||
box-shadow:
|
||||
0 0 30px 10px #fff,
|
||||
0 0 60px 20px #d8b4fe,
|
||||
0 0 120px 40px #a855f7,
|
||||
0 0 250px 80px #7e22ce;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-beam-core {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100vw;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
filter: blur(2px);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* ── Orbit Rings Background ───────────────────────────────────────────── */
|
||||
.orbit-ring {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.orbit-ring::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: rgba(167, 139, 250, 0.6);
|
||||
border-radius: 50%;
|
||||
top: 14.6%;
|
||||
left: 14.6%;
|
||||
box-shadow: 0 0 10px 2px rgba(167, 139, 250, 0.8);
|
||||
}
|
||||
.orbit-ring:nth-child(2)::after {
|
||||
top: 85.3%;
|
||||
left: 85.3%;
|
||||
}
|
||||
.orbit-ring:nth-child(3)::after {
|
||||
top: 14.6%;
|
||||
left: 85.3%;
|
||||
}
|
||||
.mask-radial-fade {
|
||||
mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
|
||||
}
|
||||
28
thirdeye/dashboard/app/intelligence/IntelFooter.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
const stats = [
|
||||
{ label: "Inference Latency", value: "14 MS" },
|
||||
{ label: "Neural Encryption", value: "THIRDEYE-X9" },
|
||||
{ label: "Active Connectors", value: "14 / 16" },
|
||||
];
|
||||
|
||||
export default function IntelFooter() {
|
||||
return (
|
||||
<footer
|
||||
className="mt-20 pt-8 flex flex-wrap justify-between items-center gap-8"
|
||||
style={{ borderTop: "1px solid rgba(167,139,250,0.1)" }}
|
||||
>
|
||||
<div className="flex items-center space-x-12">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="text-[10px] font-mono-data uppercase tracking-widest mb-1" style={{ color: "rgba(249,245,248,0.4)" }}>
|
||||
{s.label}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-white font-mono-data">{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono-data tracking-tighter" style={{ color: "rgba(167,139,250,0.6)" }}>
|
||||
© 2024 THIRDEYE_INTELLIGENCE // PRIVACY_FIRST_MONITORING
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
318
thirdeye/dashboard/app/intelligence/IntelligenceCards.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchAllPatterns, fetchAllSignals, Pattern, Signal } from "../lib/api";
|
||||
|
||||
type CardMetric = {
|
||||
type: string;
|
||||
label: string;
|
||||
value: string;
|
||||
width?: string;
|
||||
valueDim?: boolean;
|
||||
};
|
||||
|
||||
type CardData = {
|
||||
icon: string;
|
||||
iconBg: string;
|
||||
iconBorder: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
pulse?: boolean;
|
||||
time: string;
|
||||
description: string;
|
||||
metrics?: CardMetric[];
|
||||
recommendation?: string;
|
||||
footerLabel: string;
|
||||
footerIcon: string;
|
||||
footerDim: boolean;
|
||||
};
|
||||
|
||||
const STATIC_CARDS = [
|
||||
{
|
||||
icon: "subject",
|
||||
iconBg: "rgba(167,139,250,0.1)",
|
||||
iconBorder: "rgba(167,139,250,0.2)",
|
||||
iconColor: "#A78BFA",
|
||||
title: "SEMANTIC_PROCESSOR",
|
||||
},
|
||||
{
|
||||
icon: "mood",
|
||||
iconBg: "rgba(167,139,250,0.1)",
|
||||
iconBorder: "rgba(167,139,250,0.2)",
|
||||
iconColor: "#A78BFA",
|
||||
title: "SENTIMENT_MINER",
|
||||
},
|
||||
{
|
||||
icon: "schema",
|
||||
iconBg: "rgba(249,245,248,0.05)",
|
||||
iconBorder: "rgba(255,255,255,0.1)",
|
||||
iconColor: "rgba(249,245,248,0.4)",
|
||||
title: "PATTERN_DETECTOR",
|
||||
},
|
||||
];
|
||||
|
||||
export default function IntelligenceCards() {
|
||||
const [patterns, setPatterns] = useState<Pattern[]>([]);
|
||||
const [signals, setSignals] = useState<Signal[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [ptns, allGroups] = await Promise.all([
|
||||
fetchAllPatterns(),
|
||||
fetchAllSignals(),
|
||||
]);
|
||||
setPatterns(ptns);
|
||||
setSignals(allGroups.flatMap((g) => g.signals));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const totalSignals = signals.length;
|
||||
const sentimentSignals = signals.filter(
|
||||
(s) =>
|
||||
s.metadata.type.includes("sentiment") ||
|
||||
s.metadata.sentiment !== "neutral"
|
||||
);
|
||||
const avgSentiment =
|
||||
sentimentSignals.length > 0
|
||||
? `${Math.round((sentimentSignals.length / Math.max(totalSignals, 1)) * 100)}%`
|
||||
: "—";
|
||||
|
||||
const activePatterns = patterns.filter((p) => p.is_active);
|
||||
const criticalPatterns = patterns.filter((p) => p.severity === "critical");
|
||||
|
||||
const cardData: CardData[] = [
|
||||
{
|
||||
...STATIC_CARDS[0],
|
||||
time: loading ? "LOADING..." : `${totalSignals} SIGNALS`,
|
||||
description: loading
|
||||
? "Loading signal data..."
|
||||
: `Processing deep contextual inference across ${totalSignals} signals. Semantic alignment analysis running across all active node clusters.`,
|
||||
metrics: [
|
||||
{
|
||||
type: "bar",
|
||||
label: "Coverage",
|
||||
value: totalSignals > 0 ? "active" : "0%",
|
||||
width: totalSignals > 0 ? "88%" : "0%",
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
label: "Signals Indexed",
|
||||
value: String(totalSignals),
|
||||
},
|
||||
],
|
||||
footerLabel: "Inference Details",
|
||||
footerIcon: "arrow_forward",
|
||||
footerDim: false,
|
||||
},
|
||||
{
|
||||
...STATIC_CARDS[1],
|
||||
pulse: true,
|
||||
time: loading ? "LOADING..." : `${sentimentSignals.length} SAMPLES`,
|
||||
description: loading
|
||||
? "Loading sentiment data..."
|
||||
: `Identifying emotional flux patterns within communications. Analyzed ${sentimentSignals.length} sentiment-bearing signals out of ${totalSignals} total.`,
|
||||
recommendation:
|
||||
sentimentSignals.length > 0
|
||||
? `${avgSentiment} of signals carry sentiment signals. Monitor channels with high emotional flux.`
|
||||
: undefined,
|
||||
footerLabel: "Refine Analysis",
|
||||
footerIcon: "arrow_forward",
|
||||
footerDim: false,
|
||||
},
|
||||
{
|
||||
...STATIC_CARDS[2],
|
||||
time: loading ? "LOADING..." : `${activePatterns.length} ACTIVE`,
|
||||
description: loading
|
||||
? "Loading pattern data..."
|
||||
: activePatterns.length > 0
|
||||
? `${activePatterns.length} active patterns detected across all groups. ${criticalPatterns.length} require immediate attention.`
|
||||
: "No patterns detected yet. Patterns emerge as more signals are processed.",
|
||||
metrics:
|
||||
activePatterns.length > 0
|
||||
? [
|
||||
{
|
||||
type: "row",
|
||||
label: "Active Patterns",
|
||||
value: String(activePatterns.length),
|
||||
valueDim: false,
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
label: "Critical",
|
||||
value: criticalPatterns.length > 0 ? `${criticalPatterns.length} CRITICAL` : "NONE",
|
||||
valueDim: criticalPatterns.length > 0,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: "row",
|
||||
label: "Status",
|
||||
value: "Accumulating data",
|
||||
valueDim: true,
|
||||
},
|
||||
],
|
||||
footerLabel: activePatterns.length > 0 ? "View Patterns" : "System Initializing",
|
||||
footerIcon: activePatterns.length > 0 ? "arrow_forward" : "hourglass_empty",
|
||||
footerDim: activePatterns.length === 0,
|
||||
},
|
||||
];
|
||||
|
||||
type CardMetric = {
|
||||
type: string;
|
||||
label: string;
|
||||
value: string;
|
||||
width?: string;
|
||||
valueDim?: boolean;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{cardData.map((card, idx) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className="glass p-6 rounded-2xl border neon-border flex flex-col relative overflow-hidden group card-interactive animate-fade-in-up"
|
||||
style={{ animationDelay: `${idx * 100}ms` }}
|
||||
>
|
||||
{/* Background Glow */}
|
||||
<div className="absolute inset-0 rounded-2xl -z-10 bg-gradient-to-br from-violet-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
{/* Card Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div
|
||||
className="p-2.5 rounded-xl"
|
||||
style={{ backgroundColor: card.iconBg, border: `1px solid ${card.iconBorder}` }}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined${card.pulse ? " active-pulse" : ""}`}
|
||||
style={{ color: card.iconColor, fontSize: "24px" }}
|
||||
>
|
||||
{card.icon}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-mono-data uppercase tracking-widest"
|
||||
style={{ color: "rgba(249,245,248,0.4)" }}
|
||||
>
|
||||
{card.time}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="flex-1 mt-4">
|
||||
<h3 className="text-lg font-bold tracking-tight text-white mb-2 uppercase">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed mb-6" style={{ color: "rgba(249,245,248,0.7)" }}>
|
||||
{card.description}
|
||||
</p>
|
||||
|
||||
{card.recommendation && (
|
||||
<div
|
||||
className="p-4 rounded-xl mb-6"
|
||||
style={{
|
||||
backgroundColor: "rgba(167,139,250,0.05)",
|
||||
border: "1px solid rgba(167,139,250,0.1)",
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] uppercase font-bold mb-1" style={{ color: "#A78BFA" }}>
|
||||
Recommended Action
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed" style={{ color: "rgba(249,245,248,0.8)" }}>
|
||||
{card.recommendation}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.metrics && (
|
||||
<div className="space-y-4 mb-6">
|
||||
{(card.metrics as CardMetric[]).map((m) =>
|
||||
m.type === "bar" ? (
|
||||
<div key={m.label} className="flex flex-col">
|
||||
<div className="flex justify-between items-end mb-1.5">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-widest"
|
||||
style={{ color: "rgba(249,245,248,0.4)" }}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-mono-data"
|
||||
style={{ color: "#A78BFA" }}
|
||||
>
|
||||
{m.value}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="w-full h-1 rounded-full overflow-hidden"
|
||||
style={{ backgroundColor: "rgba(255,255,255,0.05)" }}
|
||||
>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
width: m.width,
|
||||
background: "linear-gradient(to right, #A78BFA, #b79fff)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={m.label}
|
||||
className="flex justify-between items-center py-2"
|
||||
style={{ borderBottom: "1px solid rgba(255,255,255,0.05)" }}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] uppercase"
|
||||
style={{ color: "rgba(249,245,248,0.4)" }}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-mono-data"
|
||||
style={{
|
||||
color: m.valueDim ? "#A78BFA" : "rgba(249,245,248,1)",
|
||||
}}
|
||||
>
|
||||
{m.value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Footer */}
|
||||
<div
|
||||
className="px-6 py-4 flex justify-between items-center cursor-pointer group/footer btn-interactive"
|
||||
style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-bold uppercase tracking-[0.2em] group-hover/footer:translate-x-1 transition-transform"
|
||||
style={{ color: card.footerDim ? "rgba(249,245,248,0.4)" : "#A78BFA" }}
|
||||
>
|
||||
{card.footerLabel}
|
||||
</span>
|
||||
<span
|
||||
className="material-symbols-outlined text-sm group-hover/footer:translate-x-1 transition-transform"
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
color: card.footerDim ? "rgba(249,245,248,0.4)" : "#A78BFA",
|
||||
}}
|
||||
>
|
||||
{card.footerIcon}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
thirdeye/dashboard/app/intelligence/KnowledgeMesh.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
export default function KnowledgeMesh() {
|
||||
return (
|
||||
<div className="xl:col-span-2 glass-card p-6 rounded-2xl relative overflow-hidden">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold tracking-tight text-white uppercase">
|
||||
Global Knowledge Mesh
|
||||
</h3>
|
||||
<p className="text-[10px] font-mono-data mt-1 uppercase" style={{ color: "rgba(249,245,248,0.4)" }}>
|
||||
Cross-Source Entity Integration / Real-time Feed
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className="px-2 py-1 rounded text-[10px] font-mono-data"
|
||||
style={{
|
||||
backgroundColor: "#131315",
|
||||
border: "1px solid rgba(167,139,250,0.2)",
|
||||
color: "#A78BFA",
|
||||
}}
|
||||
>
|
||||
KNOWLEDGE_GRAPH
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visualization area */}
|
||||
<div
|
||||
className="h-64 w-full rounded-xl flex items-center justify-center relative overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
}}
|
||||
>
|
||||
{/* Dot grid background */}
|
||||
<div className="absolute inset-0 opacity-20 pointer-events-none">
|
||||
<div className="absolute inset-0 dot-grid" />
|
||||
</div>
|
||||
|
||||
{/* Animated SVG node network */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full opacity-20"
|
||||
viewBox="0 0 600 256"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
{/* Connection lines */}
|
||||
<line x1="300" y1="128" x2="180" y2="80" stroke="#A78BFA" strokeWidth="0.5" />
|
||||
<line x1="300" y1="128" x2="420" y2="80" stroke="#A78BFA" strokeWidth="0.5" />
|
||||
<line x1="300" y1="128" x2="180" y2="176" stroke="#A78BFA" strokeWidth="0.5" />
|
||||
<line x1="300" y1="128" x2="420" y2="176" stroke="#A78BFA" strokeWidth="0.5" />
|
||||
<line x1="180" y1="80" x2="90" y2="50" stroke="#A78BFA" strokeWidth="0.3" />
|
||||
<line x1="420" y1="80" x2="510" y2="50" stroke="#A78BFA" strokeWidth="0.3" />
|
||||
<line x1="180" y1="176" x2="90" y2="206" stroke="#A78BFA" strokeWidth="0.3" />
|
||||
<line x1="420" y1="176" x2="510" y2="206" stroke="#A78BFA" strokeWidth="0.3" />
|
||||
{/* Secondary nodes */}
|
||||
<circle cx="180" cy="80" r="3" fill="#A78BFA" opacity="0.6" />
|
||||
<circle cx="420" cy="80" r="3" fill="#A78BFA" opacity="0.6" />
|
||||
<circle cx="180" cy="176" r="3" fill="#A78BFA" opacity="0.6" />
|
||||
<circle cx="420" cy="176" r="3" fill="#A78BFA" opacity="0.6" />
|
||||
<circle cx="90" cy="50" r="2" fill="#A78BFA" opacity="0.4" />
|
||||
<circle cx="510" cy="50" r="2" fill="#A78BFA" opacity="0.4" />
|
||||
<circle cx="90" cy="206" r="2" fill="#A78BFA" opacity="0.4" />
|
||||
<circle cx="510" cy="206" r="2" fill="#A78BFA" opacity="0.4" />
|
||||
</svg>
|
||||
|
||||
{/* Central pulsing node */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full active-pulse"
|
||||
style={{ backgroundColor: "#A78BFA" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-6 -left-12 backdrop-blur-md px-3 py-1 rounded text-[10px] font-mono-data text-white whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: "rgba(19,19,21,0.8)",
|
||||
border: "1px solid rgba(167,139,250,0.2)",
|
||||
}}
|
||||
>
|
||||
ENTITY_CLUSTER_09
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
thirdeye/dashboard/app/intelligence/ThoughtStreams.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchAllSignals, Signal, formatRelativeTime, getSeverityColor } from "../lib/api";
|
||||
|
||||
export default function ThoughtStreams() {
|
||||
const [signals, setSignals] = 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, 10);
|
||||
setSignals(flat);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="glass-card p-6 rounded-2xl flex flex-col">
|
||||
<h3 className="text-sm font-bold tracking-tight text-white uppercase mb-6">
|
||||
Thought Streams
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className="space-y-4 flex-1 overflow-y-auto pr-2 custom-scrollbar"
|
||||
style={{ maxHeight: "16rem" }}
|
||||
>
|
||||
{loading && (
|
||||
<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 streams...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && signals.length === 0 && (
|
||||
<p className="text-[10px] text-zinc-600 font-mono-data">
|
||||
No signals yet. Streams will appear here as groups send messages.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{signals.map((sig, i) => (
|
||||
<div key={i} className="flex items-start space-x-3 text-[10px]">
|
||||
<span className="font-mono-data shrink-0" style={{ color: "#A78BFA" }}>
|
||||
{formatRelativeTime(sig.metadata.timestamp)}
|
||||
</span>
|
||||
<p
|
||||
className="font-mono-data leading-relaxed"
|
||||
style={{ color: "rgba(249,245,248,0.6)" }}
|
||||
>
|
||||
<span style={{ color: getSeverityColor(sig.metadata.severity) }}>
|
||||
{sig.metadata.type.toUpperCase()}:
|
||||
</span>{" "}
|
||||
{sig.document.slice(0, 80)}
|
||||
{sig.document.length > 80 ? "…" : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="mt-6 w-full py-3 rounded-xl text-[10px] font-mono-data uppercase tracking-widest transition-all hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: "rgba(167,139,250,0.05)",
|
||||
border: "1px solid rgba(167,139,250,0.2)",
|
||||
color: "#A78BFA",
|
||||
}}
|
||||
>
|
||||
Export Intelligence Data
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
thirdeye/dashboard/app/intelligence/intelligence.css
Normal file
@@ -0,0 +1,49 @@
|
||||
/* Intelligence cards — glass style from Stitch */
|
||||
.glass-card {
|
||||
background: linear-gradient(180deg, rgba(28, 20, 45, 0.7) 0%, rgba(18, 14, 28, 0.9) 100%);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(167, 139, 250, 0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glass-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 20%;
|
||||
height: 60%;
|
||||
width: 2px;
|
||||
background: linear-gradient(to bottom, transparent, #A78BFA, transparent);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: rgba(167, 139, 250, 0.3);
|
||||
box-shadow: 0 0 30px rgba(167, 139, 250, 0.1);
|
||||
}
|
||||
|
||||
.neon-glow-violet {
|
||||
box-shadow: 0 0 20px rgba(167, 139, 250, 0.15);
|
||||
}
|
||||
|
||||
.active-pulse {
|
||||
box-shadow: 0 0 15px rgba(167, 139, 250, 0.4);
|
||||
animation: intel-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes intel-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.font-mono-data {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* dot-grid background for knowledge mesh */
|
||||
.dot-grid {
|
||||
background-image: radial-gradient(circle at 2px 2px, #A78BFA 1px, transparent 0);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
107
thirdeye/dashboard/app/intelligence/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import TopBar from "../components/TopBar";
|
||||
import IntelligenceCards from "./IntelligenceCards";
|
||||
import KnowledgeMesh from "./KnowledgeMesh";
|
||||
import ThoughtStreams from "./ThoughtStreams";
|
||||
import IntelFooter from "./IntelFooter";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchGroups, fetchAllSignals, fetchAllPatterns } from "../lib/api";
|
||||
|
||||
export default function IntelligencePage() {
|
||||
const [signalCount, setSignalCount] = useState<number | null>(null);
|
||||
const [patternCount, setPatternCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [allGroups, patterns] = await Promise.all([
|
||||
fetchAllSignals(),
|
||||
fetchAllPatterns(),
|
||||
]);
|
||||
setSignalCount(allGroups.flatMap((g) => g.signals).length);
|
||||
setPatternCount(patterns.filter((p) => p.is_active).length);
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-screen overflow-hidden bg-[#09090B] text-white"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-[240px] flex flex-col h-screen overflow-hidden">
|
||||
<TopBar />
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar px-10 pb-12 pt-10">
|
||||
{/* Page Header */}
|
||||
<header className="mb-12">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1
|
||||
className="text-4xl font-bold tracking-tight text-white"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Intelligence Control
|
||||
</h1>
|
||||
<p
|
||||
className="text-xs mt-2 tracking-widest uppercase font-mono-data"
|
||||
style={{ color: "#A78BFA" }}
|
||||
>
|
||||
ACTIVE_INSIGHTS:{" "}
|
||||
{signalCount !== null ? signalCount.toLocaleString() : "—"} //
|
||||
PATTERNS_ACTIVE:{" "}
|
||||
{patternCount !== null ? patternCount : "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status pill */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div
|
||||
className="px-4 py-2 rounded-xl flex items-center space-x-3"
|
||||
style={{ backgroundColor: "#131315" }}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full active-pulse"
|
||||
style={{ backgroundColor: "#A78BFA", display: "inline-block" }}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-mono-data uppercase"
|
||||
style={{ color: "rgba(249,245,248,0.6)" }}
|
||||
>
|
||||
Inference Engine: Optimal
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main card grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Top row: 3 intelligence engine cards */}
|
||||
<div className="lg:col-span-3">
|
||||
<IntelligenceCards />
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Knowledge mesh (2 cols) + Thought Streams (1 col) */}
|
||||
<div className="lg:col-span-2">
|
||||
<KnowledgeMesh />
|
||||
</div>
|
||||
<div className="lg:col-span-1">
|
||||
<ThoughtStreams />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer stats bar */}
|
||||
<IntelFooter />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
924
thirdeye/dashboard/app/jira/page.tsx
Normal file
@@ -0,0 +1,924 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import TopBar from "../components/TopBar";
|
||||
import {
|
||||
fetchJiraTickets,
|
||||
fetchJiraConfig,
|
||||
fetchJiraTicketStatus,
|
||||
createJiraTicket,
|
||||
searchJiraUsers,
|
||||
JiraTicket,
|
||||
JiraConfig,
|
||||
Group,
|
||||
fetchGroups,
|
||||
formatRelativeTime,
|
||||
} from "../lib/api";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString("en-GB", {
|
||||
day: "2-digit", month: "short", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function priorityColor(priority: string): string {
|
||||
switch ((priority || "").toLowerCase()) {
|
||||
case "highest": return "#EF4444";
|
||||
case "high": return "#F97316";
|
||||
case "medium": return "#EAB308";
|
||||
case "low": return "#3B82F6";
|
||||
case "lowest": return "#71717A";
|
||||
default: return "#A78BFA";
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
const s = (status || "").toLowerCase();
|
||||
if (s.includes("done") || s.includes("closed") || s.includes("resolved")) return "#34D399";
|
||||
if (s.includes("progress") || s.includes("review")) return "#60A5FA";
|
||||
if (s.includes("todo") || s.includes("open") || s === "unknown") return "#A78BFA";
|
||||
return "#EAB308";
|
||||
}
|
||||
|
||||
function PriorityBadge({ priority }: { priority: string }) {
|
||||
const color = priorityColor(priority);
|
||||
return (
|
||||
<span
|
||||
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
|
||||
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
|
||||
>
|
||||
{priority || "Medium"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const color = statusColor(status);
|
||||
return (
|
||||
<span
|
||||
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border flex items-center gap-1"
|
||||
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: color }} />
|
||||
{status === "Unknown" ? "Pending" : status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Create Ticket Modal ───────────────────────────────────────────────────────
|
||||
|
||||
type JiraUser = { account_id: string; display_name: string; email: string };
|
||||
|
||||
function CreateTicketModal({
|
||||
config,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
config: JiraConfig;
|
||||
onClose: () => void;
|
||||
onCreated: (ticket: { key: string; url: string }) => void;
|
||||
}) {
|
||||
const [summary, setSummary] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [projectKey, setProjectKey] = useState(config.default_project || "");
|
||||
const [issueType, setIssueType] = useState("Task");
|
||||
const [priority, setPriority] = useState("Medium");
|
||||
const [labels, setLabels] = useState("thirdeye");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [errorDetails, setErrorDetails] = useState<string>("");
|
||||
|
||||
// Assignee search
|
||||
const [assigneeQuery, setAssigneeQuery] = useState("");
|
||||
const [assigneeSuggestions, setAssigneeSuggestions] = useState<JiraUser[]>([]);
|
||||
const [selectedAssignee, setSelectedAssignee] = useState<JiraUser | null>(null);
|
||||
const [searchingAssignee, setSearchingAssignee] = useState(false);
|
||||
const assigneeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleAssigneeInput = (val: string) => {
|
||||
setAssigneeQuery(val);
|
||||
setSelectedAssignee(null);
|
||||
if (assigneeTimeout.current) clearTimeout(assigneeTimeout.current);
|
||||
if (val.trim().length < 2) { setAssigneeSuggestions([]); return; }
|
||||
assigneeTimeout.current = setTimeout(async () => {
|
||||
setSearchingAssignee(true);
|
||||
try {
|
||||
const users = await searchJiraUsers(val.trim());
|
||||
setAssigneeSuggestions(users);
|
||||
} catch {
|
||||
setAssigneeSuggestions([]);
|
||||
} finally {
|
||||
setSearchingAssignee(false);
|
||||
}
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const selectAssignee = (user: JiraUser) => {
|
||||
setSelectedAssignee(user);
|
||||
setAssigneeQuery(user.display_name);
|
||||
setAssigneeSuggestions([]);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!summary.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setErrorDetails("");
|
||||
try {
|
||||
const result = await createJiraTicket({
|
||||
summary: summary.trim(),
|
||||
description: description.trim(),
|
||||
project_key: projectKey || undefined,
|
||||
issue_type: issueType,
|
||||
priority,
|
||||
labels: labels.split(",").map((l) => l.trim()).filter(Boolean),
|
||||
assignee_account_id: selectedAssignee?.account_id || undefined,
|
||||
});
|
||||
if (result.ok && result.key) {
|
||||
onCreated({ key: result.key, url: result.url || "" });
|
||||
} else {
|
||||
setError(result.error || "Failed to create ticket");
|
||||
if (result.details) {
|
||||
const d = result.details;
|
||||
if (typeof d === "object" && d !== null) {
|
||||
setErrorDetails(JSON.stringify(d, null, 2));
|
||||
} else {
|
||||
setErrorDetails(String(d));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div
|
||||
className="relative neon-card-gradient rounded-2xl border border-[#A78BFA]/20 w-full max-w-lg shadow-2xl animate-fade-in-scale opacity-0 max-h-[90vh] overflow-y-auto"
|
||||
style={{ boxShadow: "0 0 60px rgba(167,139,250,0.12)" }}
|
||||
>
|
||||
<div className="p-6 border-b border-white/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-[rgba(167,139,250,0.15)] flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "16px" }}>add_task</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[14px] font-bold text-white">Create Jira Ticket</h3>
|
||||
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">Manual ticket creation</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Summary *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="Short, actionable ticket title..."
|
||||
required
|
||||
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Full description, context, next steps..."
|
||||
rows={3}
|
||||
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project / Type / Priority */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Project Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectKey}
|
||||
onChange={(e) => setProjectKey(e.target.value.toUpperCase())}
|
||||
placeholder={config.default_project || "ENG"}
|
||||
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50 font-mono-data"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={issueType}
|
||||
onChange={(e) => setIssueType(e.target.value)}
|
||||
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
>
|
||||
{["Task", "Bug", "Story", "Epic"].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
>
|
||||
{["Highest", "High", "Medium", "Low", "Lowest"].map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignee search */}
|
||||
<div className="relative">
|
||||
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Assignee
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" style={{ fontSize: "15px" }}>
|
||||
{selectedAssignee ? "account_circle" : "person_search"}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={assigneeQuery}
|
||||
onChange={(e) => handleAssigneeInput(e.target.value)}
|
||||
placeholder="Search by name or email..."
|
||||
className="w-full bg-[#141419] border border-white/10 rounded-lg pl-9 pr-9 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
style={{ borderColor: selectedAssignee ? "rgba(167,139,250,0.4)" : undefined }}
|
||||
/>
|
||||
{searchingAssignee && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 w-3 h-3 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin" />
|
||||
)}
|
||||
{selectedAssignee && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedAssignee(null); setAssigneeQuery(""); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Dropdown suggestions */}
|
||||
{assigneeSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-[#1a1a20] border border-white/10 rounded-lg shadow-2xl overflow-hidden">
|
||||
{assigneeSuggestions.map((u) => (
|
||||
<button
|
||||
key={u.account_id}
|
||||
type="button"
|
||||
onClick={() => selectAssignee(u)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-[#A78BFA]/10 transition-colors"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-[#A78BFA]/20 flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "14px" }}>person</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] text-zinc-200 truncate">{u.display_name}</p>
|
||||
<p className="text-[10px] text-zinc-500 truncate">{u.email}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{assigneeQuery.trim().length >= 2 && !searchingAssignee && assigneeSuggestions.length === 0 && !selectedAssignee && (
|
||||
<p className="mt-1 text-[10px] text-zinc-600">No users found for “{assigneeQuery}”</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-1.5">
|
||||
Labels <span className="text-zinc-600 normal-case font-normal">(comma-separated, no spaces in label)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={labels}
|
||||
onChange={(e) => setLabels(e.target.value)}
|
||||
placeholder="thirdeye,dashboard"
|
||||
className="w-full bg-[#141419] border border-white/10 rounded-lg px-3 py-2.5 text-[13px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 space-y-1">
|
||||
<p className="text-[12px] text-red-400 font-semibold">{error}</p>
|
||||
{errorDetails && (
|
||||
<pre className="text-[10px] text-red-500/70 whitespace-pre-wrap break-all font-mono-data max-h-24 overflow-y-auto">
|
||||
{errorDetails}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border border-white/10 text-[12px] text-zinc-400 hover:text-zinc-200 hover:border-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !summary.trim()}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-[#A78BFA] text-[12px] font-semibold text-[#1a0040] hover:bg-[#c4b5fd] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-4 h-4 border-2 border-[#1a0040]/30 border-t-[#1a0040] rounded-full animate-spin" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "16px" }}>add_task</span>
|
||||
)}
|
||||
{loading ? "Creating..." : "Create Ticket"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ticket Detail Modal ───────────────────────────────────────────────────────
|
||||
|
||||
function TicketDetailModal({
|
||||
ticket,
|
||||
onClose,
|
||||
onRefreshStatus,
|
||||
}: {
|
||||
ticket: JiraTicket;
|
||||
onClose: () => void;
|
||||
onRefreshStatus: (key: string) => void;
|
||||
}) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await onRefreshStatus(ticket.jira_key);
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const openInJira = () => {
|
||||
if (ticket.jira_url) window.open(ticket.jira_url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const priorColor = priorityColor(ticket.jira_priority);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative w-full max-w-md rounded-2xl border border-[#A78BFA]/20 shadow-2xl overflow-hidden"
|
||||
style={{ background: "linear-gradient(135deg,#0f0c18 0%,#13101f 100%)", boxShadow: "0 0 60px rgba(167,139,250,0.15)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top accent bar coloured by priority */}
|
||||
<div className="h-[3px] w-full" style={{ background: `linear-gradient(90deg, ${priorColor}, transparent)` }} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-5 pb-4 flex items-start justify-between gap-3 border-b border-white/5">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 bg-[rgba(167,139,250,0.15)]">
|
||||
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "18px" }}>confirmation_number</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-mono-data text-[#A78BFA] tracking-widest uppercase mb-0.5">{ticket.jira_key || "—"}</p>
|
||||
<h3 className="text-[14px] font-bold text-white leading-snug line-clamp-2">
|
||||
{ticket.jira_summary || "(no summary)"}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 transition-colors flex-shrink-0 mt-0.5">
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
{/* Status + Priority row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
||||
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-2">Status</p>
|
||||
<StatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<div className="bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
||||
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-2">Priority</p>
|
||||
<PriorityBadge priority={ticket.jira_priority} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-[12px]">
|
||||
<div className="bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
||||
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-1.5">Raised</p>
|
||||
<p className="text-zinc-300 font-medium">{formatDate(ticket.raised_at)}</p>
|
||||
</div>
|
||||
<div className="bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
||||
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-1.5">Group</p>
|
||||
<p className="text-zinc-300 font-mono-data truncate">{ticket.group_id || "—"}</p>
|
||||
</div>
|
||||
{ticket.assignee && (
|
||||
<div className="col-span-2 bg-[#0c0814]/60 rounded-xl p-3.5 border border-white/5">
|
||||
<p className="text-[9px] font-mono-data text-zinc-500 uppercase tracking-widest mb-1.5">Assignee</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-zinc-400" style={{ fontSize: "16px" }}>account_circle</span>
|
||||
<p className="text-zinc-300">{ticket.assignee}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Jira URL preview */}
|
||||
{ticket.jira_url && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-[#0c0814]/60 border border-white/5">
|
||||
<span className="material-symbols-outlined text-zinc-600" style={{ fontSize: "14px" }}>link</span>
|
||||
<p className="text-[10px] font-mono-data text-zinc-600 truncate flex-1">{ticket.jira_url}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="px-6 pb-6 flex items-center gap-3">
|
||||
{/* Primary — open in Jira */}
|
||||
<button
|
||||
onClick={openInJira}
|
||||
disabled={!ticket.jira_url}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-[#A78BFA] text-[13px] font-bold text-[#1a0040] hover:bg-[#c4b5fd] active:scale-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-[0_0_20px_rgba(167,139,250,0.3)]"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "17px" }}>open_in_new</span>
|
||||
Open in Jira
|
||||
</button>
|
||||
|
||||
{/* Secondary — refresh */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || !ticket.jira_key}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-xl border border-white/10 text-zinc-400 hover:text-[#A78BFA] hover:border-[#A78BFA]/30 active:scale-95 transition-all disabled:opacity-40"
|
||||
title="Refresh status from Jira"
|
||||
>
|
||||
<span className={`material-symbols-outlined ${refreshing ? "animate-spin" : ""}`} style={{ fontSize: "18px" }}>refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ticket Row ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TicketRow({
|
||||
ticket,
|
||||
onSelect,
|
||||
onRefreshStatus,
|
||||
}: {
|
||||
ticket: JiraTicket;
|
||||
onSelect: (t: JiraTicket) => void;
|
||||
onRefreshStatus: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-white/5 cursor-pointer hover:bg-[rgba(167,139,250,0.07)] active:bg-[rgba(167,139,250,0.12)] transition-colors group"
|
||||
onClick={() => onSelect(ticket)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[#A78BFA] font-bold text-[12px] font-mono-data group-hover:underline underline-offset-2">
|
||||
{ticket.jira_key || "—"}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-zinc-700 group-hover:text-[#A78BFA] transition-colors" style={{ fontSize: "11px" }}>
|
||||
chevron_right
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-[300px]">
|
||||
<p className="text-[12px] text-zinc-300 truncate group-hover:text-white transition-colors">{ticket.jira_summary || "—"}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={ticket.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<PriorityBadge priority={ticket.jira_priority} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] text-zinc-500 font-mono-data">{ticket.group_id || "—"}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] text-zinc-500">{formatDate(ticket.raised_at)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{ticket.jira_key && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRefreshStatus(ticket.jira_key); }}
|
||||
className="text-zinc-600 hover:text-[#A78BFA] transition-colors"
|
||||
title="Refresh status from Jira"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "16px" }}>refresh</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type SortKey = "raised_at" | "jira_key" | "jira_priority" | "status";
|
||||
|
||||
export default function JiraPage() {
|
||||
const [tickets, setTickets] = useState<JiraTicket[]>([]);
|
||||
const [config, setConfig] = useState<JiraConfig | null>(null);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<JiraTicket | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
|
||||
// Filters
|
||||
const [filterGroup, setFilterGroup] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState("");
|
||||
const [filterPriority, setFilterPriority] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Sorting
|
||||
const [sortKey, setSortKey] = useState<SortKey>("raised_at");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tix, cfg, grps] = await Promise.all([
|
||||
fetchJiraTickets({ group_id: filterGroup || undefined, date_from: dateFrom || undefined, date_to: dateTo || undefined }),
|
||||
fetchJiraConfig(),
|
||||
fetchGroups(),
|
||||
]);
|
||||
setTickets(tix);
|
||||
setConfig(cfg);
|
||||
setGroups(grps);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterGroup, dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const refreshStatus = async (key: string) => {
|
||||
try {
|
||||
const data = await fetchJiraTicketStatus(key);
|
||||
setTickets((prev) =>
|
||||
prev.map((t) =>
|
||||
t.jira_key === key
|
||||
? { ...t, status: data.status, assignee: data.assignee, jira_summary: data.summary || t.jira_summary }
|
||||
: t
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Status refresh failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreated = (ticket: { key: string; url: string }) => {
|
||||
setShowCreate(false);
|
||||
setSuccessMsg(`Ticket ${ticket.key} created successfully!`);
|
||||
setTimeout(() => setSuccessMsg(""), 4000);
|
||||
load();
|
||||
};
|
||||
|
||||
// Filter + sort
|
||||
const severityRank: Record<string, number> = { Highest: 5, High: 4, Medium: 3, Low: 2, Lowest: 1 };
|
||||
|
||||
const displayed = tickets
|
||||
.filter((t) => {
|
||||
if (filterStatus && t.status !== filterStatus) return false;
|
||||
if (filterPriority && (t.jira_priority || "").toLowerCase() !== filterPriority.toLowerCase()) return false;
|
||||
if (search && !t.jira_key.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!t.jira_summary.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortKey === "raised_at") cmp = a.raised_at.localeCompare(b.raised_at);
|
||||
else if (sortKey === "jira_key") cmp = a.jira_key.localeCompare(b.jira_key);
|
||||
else if (sortKey === "jira_priority")
|
||||
cmp = (severityRank[a.jira_priority] || 3) - (severityRank[b.jira_priority] || 3);
|
||||
else if (sortKey === "status") cmp = a.status.localeCompare(b.status);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortAsc((a) => !a);
|
||||
else { setSortKey(key); setSortAsc(false); }
|
||||
};
|
||||
|
||||
const SortIcon = ({ k }: { k: SortKey }) => (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "12px" }}>
|
||||
{sortKey !== k ? "unfold_more" : sortAsc ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
);
|
||||
|
||||
const openCount = tickets.filter(t => !["Done", "Closed", "Resolved"].includes(t.status)).length;
|
||||
const doneCount = tickets.filter(t => ["Done", "Closed", "Resolved"].includes(t.status)).length;
|
||||
const criticalCount = tickets.filter(t => ["Highest", "High"].includes(t.jira_priority)).length;
|
||||
|
||||
const exportCSV = () => {
|
||||
const header = ["Key", "Summary", "Status", "Priority", "Group", "Raised At"].join(",");
|
||||
const rows = displayed.map((t) =>
|
||||
[t.jira_key, `"${t.jira_summary.replace(/"/g, '""')}"`, t.status, t.jira_priority, t.group_id, t.raised_at].join(",")
|
||||
);
|
||||
const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = "jira_tickets.csv"; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex" style={{ backgroundColor: "#09090B" }}>
|
||||
<Sidebar />
|
||||
<div className="flex-1 ml-[240px] flex flex-col min-h-screen">
|
||||
<TopBar />
|
||||
<main className="flex-1 p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-8 animate-fade-in-up opacity-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-[rgba(167,139,250,0.15)] flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-[#A78BFA]">bug_report</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white tracking-tight">Jira Tickets</h1>
|
||||
<p className="text-[11px] text-zinc-500 uppercase tracking-[0.2em]">
|
||||
ThirdEye-raised · {config?.default_project || "All Projects"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{config?.configured && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-wider">
|
||||
Jira Connected
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={exportCSV}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 text-[11px] text-zinc-400 hover:text-zinc-200 hover:border-white/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>download</span>
|
||||
Export CSV
|
||||
</button>
|
||||
{config?.configured && (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#A78BFA] text-[12px] font-semibold text-[#1a0040] hover:bg-[#c4b5fd] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "16px" }}>add</span>
|
||||
New Ticket
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success message */}
|
||||
{successMsg && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-[12px] text-emerald-400 flex items-center gap-2 animate-fade-in-up opacity-0">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "16px" }}>check_circle</span>
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jira not configured banner */}
|
||||
{config && !config.configured && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-amber-400">warning</span>
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-amber-300">Jira not configured</p>
|
||||
<p className="text-[11px] text-amber-600 mt-0.5">
|
||||
Set JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN and ENABLE_JIRA=true in your .env to connect Jira.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6 animate-fade-in-up opacity-0 delay-100">
|
||||
{[
|
||||
{ label: "Total Raised", value: tickets.length, icon: "confirmation_number", color: "#A78BFA" },
|
||||
{ label: "Open", value: openCount, icon: "radio_button_unchecked", color: "#60A5FA" },
|
||||
{ label: "Resolved", value: doneCount, icon: "check_circle", color: "#34D399" },
|
||||
{ label: "High Priority", value: criticalCount, icon: "priority_high", color: "#F87171" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="neon-card-gradient rounded-xl p-4 border border-white/5 flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ background: `${s.color}18` }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "18px", color: s.color }}>{s.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{s.value}</p>
|
||||
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">{s.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap mb-5 animate-fade-in-up opacity-0 delay-200">
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" style={{ fontSize: "15px" }}>
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search key or summary..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="bg-[#141419] border border-white/10 rounded-lg pl-9 pr-4 py-2 text-[12px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50 w-52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filterGroup}
|
||||
onChange={(e) => setFilterGroup(e.target.value)}
|
||||
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
>
|
||||
<option value="">All Groups</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.group_id} value={g.group_id}>{g.group_name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{["To Do", "In Progress", "In Review", "Done", "Unknown"].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterPriority}
|
||||
onChange={(e) => setFilterPriority(e.target.value)}
|
||||
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
{["Highest", "High", "Medium", "Low", "Lowest"].map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
title="From date"
|
||||
value={dateFrom ? dateFrom.slice(0, 10) : ""}
|
||||
onChange={(e) => setDateFrom(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
||||
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
title="To date"
|
||||
value={dateTo ? dateTo.slice(0, 10) : ""}
|
||||
onChange={(e) => setDateTo(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
||||
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
/>
|
||||
|
||||
{(filterGroup || filterStatus || filterPriority || dateFrom || dateTo || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterGroup(""); setFilterStatus(""); setFilterPriority(""); setDateFrom(""); setDateTo(""); setSearch(""); }}
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300 uppercase tracking-wider px-3 py-2 rounded-lg border border-white/10 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "13px" }}>filter_alt_off</span>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={load}
|
||||
className="ml-auto flex items-center gap-1.5 text-[11px] text-zinc-500 hover:text-[#A78BFA] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>refresh</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="neon-card-gradient rounded-2xl border border-white/5 overflow-hidden animate-fade-in-up opacity-0 delay-300">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
||||
<div className="w-8 h-8 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin mb-3" />
|
||||
<p className="text-[11px] uppercase tracking-wider">Loading tickets...</p>
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
||||
<span className="material-symbols-outlined text-4xl mb-2">confirmation_number</span>
|
||||
<p className="text-[13px] uppercase tracking-wider">No tickets found</p>
|
||||
<p className="text-[10px] text-zinc-700 mt-1">Tickets appear after ThirdEye raises them via /jira or auto-raise</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 bg-[rgba(255,255,255,0.02)]">
|
||||
{(
|
||||
[
|
||||
{ label: "Key", key: "jira_key" },
|
||||
{ label: "Summary", key: null },
|
||||
{ label: "Status", key: "status" },
|
||||
{ label: "Priority", key: "jira_priority" },
|
||||
{ label: "Group", key: null },
|
||||
{ label: "Raised", key: "raised_at" },
|
||||
{ label: "", key: null },
|
||||
] as { label: string; key: SortKey | null }[]
|
||||
).map(({ label, key }) => (
|
||||
<th
|
||||
key={label}
|
||||
className={`px-4 py-3 text-left text-[10px] font-bold text-zinc-500 uppercase tracking-wider ${key ? "cursor-pointer hover:text-zinc-300" : ""}`}
|
||||
onClick={key ? () => toggleSort(key) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{label}
|
||||
{key && <SortIcon k={key} />}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayed.map((ticket) => (
|
||||
<TicketRow
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
onSelect={setSelectedTicket}
|
||||
onRefreshStatus={refreshStatus}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-zinc-700 mt-3">
|
||||
Showing {displayed.length} of {tickets.length} tickets
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{showCreate && config && (
|
||||
<CreateTicketModal config={config} onClose={() => setShowCreate(false)} onCreated={handleCreated} />
|
||||
)}
|
||||
|
||||
{selectedTicket && (
|
||||
<TicketDetailModal
|
||||
ticket={selectedTicket}
|
||||
onClose={() => setSelectedTicket(null)}
|
||||
onRefreshStatus={async (key) => {
|
||||
await refreshStatus(key);
|
||||
// Update the selected ticket's status in-place
|
||||
setSelectedTicket((prev) =>
|
||||
prev
|
||||
? { ...prev, status: tickets.find((t) => t.jira_key === key)?.status ?? prev.status }
|
||||
: null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
26
thirdeye/dashboard/app/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import MaterialSymbols from './components/MaterialSymbols'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ThirdEye Dashboard',
|
||||
description: 'Multi-Agent Conversation Intelligence',
|
||||
icons: {
|
||||
icon: '/new-logo.png',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className="text-on-surface font-poppins selection:bg-primary-container selection:text-on-primary-container">
|
||||
<MaterialSymbols />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
437
thirdeye/dashboard/app/lib/api.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* ThirdEye API Client
|
||||
* Connects to the FastAPI backend at localhost:8000 (proxied via /api)
|
||||
*/
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SignalMetadata {
|
||||
type: string;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
status: string;
|
||||
sentiment: string;
|
||||
urgency: string;
|
||||
entities: string; // JSON string
|
||||
keywords: string; // JSON string
|
||||
raw_quote: string;
|
||||
timestamp: string;
|
||||
group_id: string;
|
||||
lens: string;
|
||||
meeting_id?: string;
|
||||
}
|
||||
|
||||
export interface Signal {
|
||||
document: string;
|
||||
metadata: SignalMetadata;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
signal_count: number;
|
||||
lens: string;
|
||||
}
|
||||
|
||||
export interface Pattern {
|
||||
id: string;
|
||||
group_id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
evidence_signal_ids: string[];
|
||||
recommendation: string;
|
||||
detected_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface CrossGroupInsight {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
group_a: { name?: string; group_id?: string; evidence?: string };
|
||||
group_b: { name?: string; group_id?: string; evidence?: string };
|
||||
severity: string;
|
||||
recommendation: string;
|
||||
detected_at: string;
|
||||
is_resolved: boolean;
|
||||
}
|
||||
|
||||
export interface Meeting {
|
||||
meeting_id: string;
|
||||
signal_count: number;
|
||||
types: Record<string, number>;
|
||||
started_at?: string;
|
||||
speaker?: string;
|
||||
group_id?: string;
|
||||
}
|
||||
|
||||
export interface MeetingDetail {
|
||||
meeting_id: string;
|
||||
started_at: string;
|
||||
speaker: string;
|
||||
group_id: string;
|
||||
total_signals: number;
|
||||
signal_counts: Record<string, number>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface TranscriptChunk {
|
||||
id: string;
|
||||
text: string;
|
||||
speaker: string;
|
||||
timestamp: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface JiraTicket {
|
||||
id: string;
|
||||
jira_key: string;
|
||||
jira_url: string;
|
||||
jira_summary: string;
|
||||
jira_priority: string;
|
||||
original_signal_id: string;
|
||||
group_id: string;
|
||||
raised_at: string;
|
||||
status: string;
|
||||
assignee?: string;
|
||||
}
|
||||
|
||||
export interface JiraConfig {
|
||||
configured: boolean;
|
||||
connected?: boolean;
|
||||
base_url?: string;
|
||||
default_project?: string;
|
||||
projects?: { key: string; name: string; id: string }[];
|
||||
}
|
||||
|
||||
export interface TimelineSignal extends Signal {
|
||||
group_name?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─── Groups ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchGroups(): Promise<Group[]> {
|
||||
const data = await fetchJSON<{ groups: Group[] }>(`${API_BASE}/groups`);
|
||||
return data.groups;
|
||||
}
|
||||
|
||||
// ─── Signals ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchSignals(
|
||||
groupId: string,
|
||||
signalType?: string
|
||||
): Promise<Signal[]> {
|
||||
const url = signalType
|
||||
? `${API_BASE}/groups/${groupId}/signals?signal_type=${signalType}`
|
||||
: `${API_BASE}/groups/${groupId}/signals`;
|
||||
const data = await fetchJSON<{ signals: Signal[]; count: number }>(url);
|
||||
return data.signals;
|
||||
}
|
||||
|
||||
export async function fetchAllSignals(): Promise<{ group_id: string; signals: Signal[] }[]> {
|
||||
const groups = await fetchGroups();
|
||||
const results = await Promise.allSettled(
|
||||
groups.map(async (g) => ({
|
||||
group_id: g.group_id,
|
||||
signals: await fetchSignals(g.group_id),
|
||||
}))
|
||||
);
|
||||
return results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{ group_id: string; signals: Signal[] }> =>
|
||||
r.status === "fulfilled"
|
||||
)
|
||||
.map((r) => r.value);
|
||||
}
|
||||
|
||||
// ─── Patterns ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchPatterns(groupId: string): Promise<Pattern[]> {
|
||||
const data = await fetchJSON<{ patterns: Pattern[] }>(
|
||||
`${API_BASE}/groups/${groupId}/patterns`
|
||||
);
|
||||
return data.patterns;
|
||||
}
|
||||
|
||||
export async function fetchAllPatterns(): Promise<Pattern[]> {
|
||||
const groups = await fetchGroups();
|
||||
const results = await Promise.allSettled(groups.map((g) => fetchPatterns(g.group_id)));
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<Pattern[]> => r.status === "fulfilled")
|
||||
.flatMap((r) => r.value);
|
||||
}
|
||||
|
||||
// ─── Cross-Group Insights ─────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchCrossGroupInsights(
|
||||
timeoutMs = 30000
|
||||
): Promise<CrossGroupInsight[]> {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const data = await fetchJSON<{ insights: CrossGroupInsight[]; message?: string }>(
|
||||
`${API_BASE}/cross-group/insights`,
|
||||
{ signal: ctrl.signal }
|
||||
);
|
||||
return data.insights;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Knowledge Base Query ─────────────────────────────────────────────────────
|
||||
|
||||
export async function queryKnowledge(
|
||||
groupId: string,
|
||||
question: string
|
||||
): Promise<{ answer: string; question: string }> {
|
||||
return fetchJSON(`${API_BASE}/groups/${groupId}/query`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Meetings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchMeetings(): Promise<Meeting[]> {
|
||||
const data = await fetchJSON<{ meetings: Meeting[] }>(`${API_BASE}/meet/meetings`);
|
||||
return data.meetings;
|
||||
}
|
||||
|
||||
export async function fetchMeetingDetail(meetingId: string): Promise<MeetingDetail> {
|
||||
return fetchJSON<MeetingDetail>(`${API_BASE}/meet/meetings/${encodeURIComponent(meetingId)}`);
|
||||
}
|
||||
|
||||
export async function fetchMeetingTranscript(
|
||||
meetingId: string
|
||||
): Promise<{ transcript: TranscriptChunk[]; chunk_count: number }> {
|
||||
return fetchJSON(`${API_BASE}/meet/meetings/${encodeURIComponent(meetingId)}/transcript`);
|
||||
}
|
||||
|
||||
export async function fetchMeetingSignals(meetingId: string): Promise<Signal[]> {
|
||||
const data = await fetchJSON<{ signals: Signal[]; count: number }>(
|
||||
`${API_BASE}/meet/meetings/${encodeURIComponent(meetingId)}/signals`
|
||||
);
|
||||
return data.signals;
|
||||
}
|
||||
|
||||
// ─── Jira ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface JiraTicketFilters {
|
||||
group_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchJiraTickets(filters: JiraTicketFilters = {}): Promise<JiraTicket[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.group_id) params.set("group_id", filters.group_id);
|
||||
if (filters.date_from) params.set("date_from", filters.date_from);
|
||||
if (filters.date_to) params.set("date_to", filters.date_to);
|
||||
if (filters.live) params.set("live", "true");
|
||||
const url = params.toString()
|
||||
? `${API_BASE}/jira/tickets?${params}`
|
||||
: `${API_BASE}/jira/tickets`;
|
||||
const data = await fetchJSON<{ tickets: JiraTicket[]; count: number }>(url);
|
||||
return data.tickets;
|
||||
}
|
||||
|
||||
export async function fetchJiraTicketStatus(
|
||||
ticketKey: string
|
||||
): Promise<{ key: string; status: string; assignee: string; summary: string; url: string }> {
|
||||
return fetchJSON(`${API_BASE}/jira/tickets/${encodeURIComponent(ticketKey)}/status`);
|
||||
}
|
||||
|
||||
export async function raiseJiraTicket(
|
||||
signalId: string,
|
||||
groupId: string,
|
||||
projectKey?: string,
|
||||
force = false
|
||||
): Promise<{ ok: boolean; key?: string; url?: string; summary?: string; reason?: string; error?: string }> {
|
||||
return fetchJSON(`${API_BASE}/jira/raise`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ signal_id: signalId, group_id: groupId, project_key: projectKey, force }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createJiraTicket(data: {
|
||||
summary: string;
|
||||
description?: string;
|
||||
project_key?: string;
|
||||
issue_type?: string;
|
||||
priority?: string;
|
||||
labels?: string[];
|
||||
assignee_account_id?: string;
|
||||
}): Promise<{ ok: boolean; key?: string; url?: string; error?: string; details?: unknown }> {
|
||||
return fetchJSON(`${API_BASE}/jira/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchJiraUsers(
|
||||
query: string
|
||||
): Promise<{ account_id: string; display_name: string; email: string; active: boolean }[]> {
|
||||
const data = await fetchJSON<{ users: { account_id: string; display_name: string; email: string; active: boolean }[] }>(
|
||||
`${API_BASE}/jira/users/search?q=${encodeURIComponent(query)}`
|
||||
);
|
||||
return data.users;
|
||||
}
|
||||
|
||||
export async function fetchJiraConfig(): Promise<JiraConfig> {
|
||||
return fetchJSON<JiraConfig>(`${API_BASE}/jira/config`);
|
||||
}
|
||||
|
||||
// ─── Timeline (cross-group signals) ──────────────────────────────────────────
|
||||
|
||||
export interface TimelineFilters {
|
||||
group_id?: string;
|
||||
severity?: string;
|
||||
lens?: string;
|
||||
signal_type?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function fetchTimeline(
|
||||
filters: TimelineFilters = {}
|
||||
): Promise<{ signals: TimelineSignal[]; total: number; truncated: boolean }> {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== "") params.set(k, String(v));
|
||||
});
|
||||
const url = params.toString()
|
||||
? `${API_BASE}/signals/timeline?${params}`
|
||||
: `${API_BASE}/signals/timeline`;
|
||||
return fetchJSON(url);
|
||||
}
|
||||
|
||||
// ─── Knowledge Browser ───────────────────────────────────────────────────────
|
||||
|
||||
export interface KnowledgeTopicSummary {
|
||||
name: string;
|
||||
signal_count: number;
|
||||
latest: string;
|
||||
sample_signals: string[];
|
||||
}
|
||||
|
||||
export interface KnowledgeDayEntry {
|
||||
date: string;
|
||||
signals: Signal[];
|
||||
topics: string[];
|
||||
signal_count: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeBrowseResponse {
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
total_signals: number;
|
||||
date_range: { earliest: string; latest: string };
|
||||
topics: KnowledgeTopicSummary[];
|
||||
timeline: KnowledgeDayEntry[];
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeBrowse(
|
||||
groupId: string,
|
||||
options: { dateFrom?: string; dateTo?: string; topic?: string } = {}
|
||||
): Promise<KnowledgeBrowseResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.dateFrom) params.set("date_from", options.dateFrom);
|
||||
if (options.dateTo) params.set("date_to", options.dateTo);
|
||||
if (options.topic) params.set("topic", options.topic);
|
||||
const qs = params.toString();
|
||||
return fetchJSON<KnowledgeBrowseResponse>(
|
||||
`${API_BASE}/knowledge/browse/${encodeURIComponent(groupId)}${qs ? `?${qs}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Health ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
await fetchJSON("/health");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Utility ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Format ISO timestamp to a relative "T-Xm ago" style string */
|
||||
export function formatRelativeTime(isoString: string): string {
|
||||
const now = Date.now();
|
||||
const ts = new Date(isoString).getTime();
|
||||
const diffMs = now - ts;
|
||||
const diffS = Math.floor(diffMs / 1000);
|
||||
if (diffS < 60) return `T-${diffS}s ago`;
|
||||
const diffM = Math.floor(diffS / 60);
|
||||
if (diffM < 60) return `T-${diffM}m ago`;
|
||||
const diffH = Math.floor(diffM / 60);
|
||||
if (diffH < 24) return `T-${diffH}h ago`;
|
||||
const diffD = Math.floor(diffH / 24);
|
||||
return `T-${diffD}d ago`;
|
||||
}
|
||||
|
||||
/** Get a severity color for a signal */
|
||||
export function getSeverityColor(severity: string): string {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "#ff6f78";
|
||||
case "high":
|
||||
return "#ffb300";
|
||||
case "medium":
|
||||
return "#A78BFA";
|
||||
default:
|
||||
return "#A78BFA";
|
||||
}
|
||||
}
|
||||
|
||||
/** Get an icon for a signal type */
|
||||
export function getSignalIcon(type: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
architecture_decision: "architecture",
|
||||
tech_debt: "construction",
|
||||
security_concern: "security",
|
||||
consensus: "check_circle",
|
||||
blocker: "block",
|
||||
risk: "warning",
|
||||
sentiment_spike: "mood",
|
||||
knowledge_gap: "help",
|
||||
decision: "gavel",
|
||||
action_item: "task_alt",
|
||||
trend: "trending_up",
|
||||
jira_raised: "bug_report",
|
||||
meet_started: "videocam",
|
||||
meet_transcript: "record_voice_over",
|
||||
};
|
||||
return iconMap[type] ?? "sensors";
|
||||
}
|
||||
|
||||
/** Parse a JSON string field from signal metadata safely */
|
||||
export function parseMetaList(str: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
38
thirdeye/dashboard/app/logs/EventDetails.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function EventDetails() {
|
||||
return (
|
||||
<div className="fixed right-6 bottom-32 w-80 z-40 animate-fade-in-right delay-200" style={{ right: '24px', bottom: '128px' }}>
|
||||
<div className="glass neon-border rounded-xl shadow-2xl flex flex-col bg-black/80 backdrop-blur-2xl border-white/10 overflow-hidden card-interactive">
|
||||
<div className="p-4 flex justify-between items-center border-b border-white/5 bg-white/5 relative">
|
||||
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-[#a88cfb]/50 to-transparent"></div>
|
||||
<h3 className="text-[10px] uppercase font-mono-data tracking-widest text-violet-400">Selected Event Details</h3>
|
||||
<button className="text-zinc-500 hover:text-white transition-colors cursor-pointer material-symbols-outlined text-sm">close</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-black/40 rounded-lg p-3 border border-white/5 shadow-inner">
|
||||
<p className="text-[8px] font-mono-data text-zinc-500 uppercase mb-1">Raw Signature</p>
|
||||
<p className="text-[10px] font-mono-data break-all text-[#a88cfb] opacity-90">SHA256: 8a7f11c2a10be...b9921e2a10bf</p>
|
||||
</div>
|
||||
<div className="bg-black/40 rounded-lg p-3 border border-white/5 shadow-inner flex justify-between items-center">
|
||||
<p className="text-[8px] font-mono-data text-zinc-500 uppercase">Geolocation</p>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-mono-data text-white block">Moscow, RU</span>
|
||||
<span className="text-[9px] font-mono-data font-bold italic text-[#00daf3]">PROXIED</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-white/5 bg-white/[0.02] flex gap-2">
|
||||
<button className="flex-1 bg-violet-500/20 text-violet-400 hover:bg-violet-500 hover:text-white border border-violet-500/50 py-2.5 rounded-lg text-[10px] font-bold tracking-[0.2em] transition-all uppercase btn-interactive shadow-[0_0_10px_rgba(167,139,250,0.1)] focus:ring-2 focus:ring-violet-500/50">
|
||||
Trace Route
|
||||
</button>
|
||||
<button className="px-3 bg-white/5 text-zinc-400 hover:bg-white/10 hover:text-white border border-white/10 rounded-lg transition-all flex items-center justify-center btn-interactive">
|
||||
<span className="material-symbols-outlined text-[14px]">share</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
thirdeye/dashboard/app/logs/LogAnalytics.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function LogAnalytics() {
|
||||
const metrics = [
|
||||
{ title: "Ingestion Rate", icon: "trending_up", iconColor: "#A78BFA", value: "1.2", unit: "GB/s" },
|
||||
{ title: "Active Agents", icon: "groups", iconColor: "#00daf3", value: "242" },
|
||||
{ title: "Threat Level", icon: "warning", iconColor: "#ef4444", value: "CRITICAL", sub: "3 ONGOING BREACHES" },
|
||||
{ title: "Memory Usage", icon: "memory", iconColor: "#E9D9FF", value: "84", unit: "%" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 animate-fade-in-scale">
|
||||
{metrics.map((m, idx) => (
|
||||
<div key={idx} className="bg-[#0C0814]/80 border border-[#A78BFA]/10 rounded-2xl p-5 flex flex-col justify-between h-28 card-interactive relative overflow-hidden group hover:border-[#A78BFA]/30 hover:bg-[#110D1A] transition-colors shadow-lg">
|
||||
<div className="flex justify-between items-center relative z-10 w-full">
|
||||
<span className="text-[10px] text-[#8B7BB1] tracking-[0.15em] uppercase font-bold">{m.title}</span>
|
||||
<span className="material-symbols-outlined text-[20px]" style={{ color: m.iconColor }}>{m.icon}</span>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col items-start justify-end flex-1 mt-2">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[28px] font-black text-white tracking-tight" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
|
||||
{m.value}
|
||||
</span>
|
||||
{m.unit && <span className="text-[12px] font-bold tracking-wide" style={{ color: m.iconColor }}>{m.unit}</span>}
|
||||
</div>
|
||||
{m.sub && <span className="text-[8.5px] font-mono-data text-[#8B7BB1]/80 uppercase tracking-[0.1em] mt-0.5">{m.sub}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
thirdeye/dashboard/app/logs/LogTable.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
const logs = [
|
||||
{
|
||||
timestamp: "2024-05-20 14:22:01.442",
|
||||
level: "ERROR",
|
||||
agentId: "AX-982_CORE",
|
||||
payload: { text: "Unauthorized handshake from peer", ip: "[192.168.1.104].", json: '{"action": "TERMINATE_SESSION"}' },
|
||||
icon: "dangerous",
|
||||
statusClass: "status-strip-error",
|
||||
levelClass: "bg-red-500/10 text-[#ee7d77] border-[#ee7d77]/20 shadow-[0_0_5px_rgba(238,125,119,0.2)]"
|
||||
},
|
||||
{
|
||||
timestamp: "2024-05-20 14:21:58.112",
|
||||
level: "INFO",
|
||||
agentId: "SYS_MONITOR",
|
||||
payload: { text: "Key rotation complete. Nodes synced.", ip: "", json: '{"latency": "12ms", "status": "nominal"}' },
|
||||
icon: "info",
|
||||
statusClass: "status-strip-info",
|
||||
levelClass: "bg-purple-500/10 text-[#a88cfb] border-[#a88cfb]/20"
|
||||
},
|
||||
{
|
||||
timestamp: "2024-05-20 14:21:45.890",
|
||||
level: "SUCCESS",
|
||||
agentId: "SCAN_AGENT_04",
|
||||
payload: { text: "Quantum hash verification PASSED.", ip: "", json: '{"sector": "7B", "integrity": 1.0}' },
|
||||
icon: "check_circle",
|
||||
statusClass: "status-strip-success",
|
||||
levelClass: "bg-cyan-500/10 text-[#00daf3] border-[#00daf3]/20"
|
||||
},
|
||||
{
|
||||
timestamp: "2024-05-20 14:21:12.001",
|
||||
level: "WARN",
|
||||
agentId: "THERMAL_REG",
|
||||
payload: { text: "Blade rack 4 temp high.", ip: "", json: '{"temp": "72°C", "limit": "70°C"}' },
|
||||
icon: "report_problem",
|
||||
statusClass: "status-strip-warning",
|
||||
levelClass: "bg-amber-500/10 text-[#ffb300] border-[#ffb300]/20"
|
||||
},
|
||||
{
|
||||
timestamp: "2024-05-20 14:20:55.223",
|
||||
level: "INFO",
|
||||
agentId: "USER_AUTH",
|
||||
payload: { text: "Admin login sequence initiated.", ip: "", json: '{"terminal": "ST-09", "auth": "biometric"}' },
|
||||
icon: "verified_user",
|
||||
statusClass: "status-strip-info",
|
||||
levelClass: "bg-purple-500/10 text-[#a88cfb] border-[#a88cfb]/20"
|
||||
}
|
||||
];
|
||||
|
||||
export default function LogTable() {
|
||||
return (
|
||||
<div className="glass rounded-xl border neon-border flex flex-col min-h-0 flex-1 overflow-hidden animate-fade-in-up delay-100">
|
||||
{/* Table Header / Controls */}
|
||||
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-black/40 backdrop-blur-md z-10 w-full shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<button className="h-8 px-3 rounded-md bg-white/5 border border-white/10 text-xs font-mono-data text-zinc-400 hover:text-violet-400 hover:bg-violet-500/20 transition-all flex items-center gap-2 btn-interactive shadow-sm">
|
||||
<span className="material-symbols-outlined text-sm">filter_alt</span>
|
||||
Filter
|
||||
</button>
|
||||
<button className="h-8 px-3 rounded-md bg-white/5 border border-white/10 text-xs font-mono-data text-zinc-400 hover:text-violet-400 hover:bg-violet-500/20 transition-all flex items-center gap-2 btn-interactive shadow-sm">
|
||||
<span className="material-symbols-outlined text-sm">sort</span>
|
||||
Sort
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">search</span>
|
||||
<input type="text" placeholder="Search logs..." className="h-8 pl-9 pr-4 rounded-md bg-black/50 border border-white/10 text-xs font-mono-data text-zinc-300 placeholder-zinc-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 w-64 transition-all" />
|
||||
</div>
|
||||
<button className="h-8 px-4 rounded-md bg-violet-500/20 text-violet-400 hover:bg-violet-500 hover:text-white border border-violet-500/50 text-xs font-bold tracking-widest uppercase transition-all flex items-center gap-2 btn-interactive shadow-[0_0_10px_rgba(167,139,250,0.1)] hover:shadow-[0_0_15px_rgba(167,139,250,0.4)]">
|
||||
<span className="material-symbols-outlined text-sm">download</span>
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Table Body */}
|
||||
<div className="flex-1 h-0 overflow-y-auto custom-scrollbar relative z-10">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="sticky top-0 bg-black/80 backdrop-blur-md z-20 border-b border-white/5">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-[10px] font-bold text-zinc-500 uppercase tracking-widest w-48" style={{ fontFamily: "'Inter Tight', sans-serif" }}>Timestamp</th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold text-zinc-500 uppercase tracking-widest w-24" style={{ fontFamily: "'Inter Tight', sans-serif" }}>Level</th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold text-zinc-500 uppercase tracking-widest w-32" style={{ fontFamily: "'Inter Tight', sans-serif" }}>Agent ID</th>
|
||||
<th className="px-6 py-3 text-[10px] font-bold text-zinc-500 uppercase tracking-widest" style={{ fontFamily: "'Inter Tight', sans-serif" }}>Message Payload</th>
|
||||
<th className="px-6 py-3 text-[10px] font-bold text-zinc-500 uppercase tracking-widest w-20 text-right" style={{ fontFamily: "'Inter Tight', sans-serif" }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{logs.map((log, idx) => (
|
||||
<tr key={idx} className={`hover:bg-[#a88cfb]/10 transition-all duration-200 group ${log.statusClass}`}>
|
||||
<td className="px-6 py-3 font-mono-data text-[11px] syntax-timestamp whitespace-nowrap">{log.timestamp}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className={`text-[9px] font-mono-data px-2 py-0.5 rounded border font-bold ${log.levelClass}`}>
|
||||
{log.level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono-data text-[11px] syntax-agent whitespace-nowrap">{log.agentId}</td>
|
||||
<td className="px-6 py-3 font-mono-data text-[11px] truncate max-w-[300px]">
|
||||
<span className="syntax-string mr-2 opacity-90">{log.payload.text}</span>
|
||||
{log.payload.ip && <span className="syntax-ip mr-2" style={{ color: "#00daf3" }}>{log.payload.ip}</span>}
|
||||
<span className="syntax-key opacity-60 text-xs">{log.payload.json}</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right whitespace-nowrap">
|
||||
<span className={`material-symbols-outlined text-[16px] drop-shadow-lg ${log.level === 'ERROR' ? 'text-[#ee7d77]' : log.level === 'SUCCESS' ? 'text-[#00daf3]' : log.level === 'WARN' ? 'text-[#ffb300]' : 'text-[#a88cfb]'}`}>
|
||||
{log.icon}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer Log Stats */}
|
||||
<div className="px-6 py-3 border-t border-white/5 flex items-center justify-between text-[10px] font-mono-data text-zinc-500 relative z-10 bg-black/60 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1.5"><span className="w-1.5 h-1.5 rounded-full animate-pulse shadow-[0_0_8px_#a88cfb]" style={{ backgroundColor: "#a88cfb" }}></span> LIVE RECAPITULATION</span>
|
||||
<span>|</span>
|
||||
<span>Showing {logs.length} events of 44,901</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span>PAGE 1 OF 3,741</span>
|
||||
<div className="flex gap-1">
|
||||
<button className="w-6 h-6 flex items-center justify-center rounded border border-white/10 hover:bg-[#a88cfb]/10 hover:text-[#a88cfb] transition-colors">
|
||||
<span className="material-symbols-outlined text-sm">chevron_left</span>
|
||||
</button>
|
||||
<button className="w-6 h-6 flex items-center justify-center rounded border border-white/10 hover:bg-[#a88cfb]/10 hover:text-[#a88cfb] transition-colors">
|
||||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
thirdeye/dashboard/app/logs/MasterTerminal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
export default function MasterTerminal() {
|
||||
return (
|
||||
<section className="h-48 shrink-0 glass-panel rounded-xl neon-border-violet crt-overlay flex flex-col overflow-hidden neon-glow-violet">
|
||||
<div className="flex items-center justify-between px-4 py-1.5 border-b relative z-10" style={{ backgroundColor: "rgba(168, 140, 251, 0.05)", borderColor: "rgba(168, 140, 251, 0.2)" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xs" style={{ color: "#a88cfb" }}>terminal</span>
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest" style={{ color: "#a88cfb", fontFamily: "'Inter Tight', sans-serif" }}>Master Terminal Output</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-zinc-700"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-zinc-700"></div>
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: "rgba(168, 140, 251, 0.4)" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4 font-mono-data text-[10px] overflow-y-auto custom-scrollbar relative z-10 space-y-1.5 leading-relaxed">
|
||||
<p className="text-zinc-500">[0.000000] Initializing ThirdEye Sovereign Kernel v4.2.0-stable...</p>
|
||||
<p className="text-zinc-500">[0.012441] Probing for regional satellite clusters... 12 found.</p>
|
||||
<p className="opacity-80" style={{ color: "#a88cfb" }}>[1.442091] Authentication layer engaged. Handshaking with local nodes.</p>
|
||||
<p className="opacity-80" style={{ color: "#00daf3" }}>[2.112440] Network topology re-aligned. Current threat vector: STABLE.</p>
|
||||
<div className="flex items-center text-white mt-2">
|
||||
<span className="mr-2" style={{ color: "#a88cfb" }}>op_7742@thirdeye:~$</span>
|
||||
<span className="opacity-70">tail -f /var/log/kernel_events.log --grep "CRITICAL"</span>
|
||||
<span className="terminal-cursor"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
20
thirdeye/dashboard/app/logs/SystemTickerLogs.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export default function SystemTickerLogs() {
|
||||
return (
|
||||
<footer className="h-8 border-t flex items-center overflow-hidden" style={{ backgroundColor: "#0C0C0E", borderColor: "rgba(167, 139, 250, 0.1)" }}>
|
||||
<div className="flex items-center whitespace-nowrap animate-log-ticker gap-12 w-full pl-[100%]">
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono-data" style={{ color: "rgba(168, 140, 251, 0.7)" }}>
|
||||
<span className="font-bold uppercase">[SYSTEM]</span> Kernel optimization complete. (v4.2.0-stable)
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono-data text-zinc-500">
|
||||
<span className="font-bold uppercase" style={{ color: "rgba(0, 218, 243, 0.7)" }}>[NET]</span> Sub-layer latency dropped below 5ms for all local clusters.
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono-data text-zinc-500">
|
||||
<span className="font-bold uppercase" style={{ color: "rgba(238, 125, 119, 0.7)" }}>[SEC]</span> Brute force attempt blocked from 45.1.22.88. Target: Archive_Vault.
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono-data" style={{ color: "rgba(168, 140, 251, 0.7)" }}>
|
||||
<span className="font-bold uppercase">[AI]</span> Neural agent AX-982 is retraining on high-entropy log clusters.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
90
thirdeye/dashboard/app/logs/logs.css
Normal file
@@ -0,0 +1,90 @@
|
||||
/* System Logs specific CSS */
|
||||
.glass-panel {
|
||||
background: linear-gradient(180deg, rgba(28, 20, 45, 0.5) 0%, rgba(18, 14, 28, 0.8) 100%);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(167, 139, 250, 0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0%;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(167, 139, 250, 0.5), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(167, 139, 250, 0.3);
|
||||
box-shadow: 0 10px 40px rgba(167, 139, 250, 0.15);
|
||||
}
|
||||
|
||||
.glass-panel:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.neon-glow-violet {
|
||||
box-shadow: 0 0 20px rgba(167, 139, 250, 0.15);
|
||||
}
|
||||
|
||||
.neon-border-violet {
|
||||
border-color: rgba(167, 139, 250, 0.3);
|
||||
}
|
||||
|
||||
.status-strip-error { border-left: 4px solid #ee7d77; }
|
||||
.status-strip-warning { border-left: 4px solid #ffb300; } /* changed yellow to standard warning */
|
||||
.status-strip-success { border-left: 4px solid #00daf3; } /* mapping success to primary cyan */
|
||||
.status-strip-info { border-left: 4px solid #a88cfb; }
|
||||
|
||||
.crt-overlay {
|
||||
position: relative;
|
||||
}
|
||||
.crt-overlay::after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0; left: 0; bottom: 0; right: 0;
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.15) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.03));
|
||||
z-index: 2;
|
||||
background-size: 100% 2px, 3px 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes terminal-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.terminal-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
background-color: #a88cfb;
|
||||
animation: terminal-blink 1s step-end infinite;
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.syntax-timestamp { color: #8b5cf6; }
|
||||
.syntax-agent { color: #c7b4ff; font-weight: 600; }
|
||||
.syntax-key { color: #a88cfb; }
|
||||
.syntax-string { color: #e7e4ec; opacity: 0.9; }
|
||||
.syntax-ip { color: #00daf3; font-style: italic; }
|
||||
|
||||
@keyframes log-ticker {
|
||||
0% { transform: translateX(100%); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
.animate-log-ticker {
|
||||
display: inline-block;
|
||||
animation: log-ticker 35s linear infinite;
|
||||
}
|
||||
42
thirdeye/dashboard/app/logs/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import "./logs.css";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import TopBar from "../components/TopBar";
|
||||
import LogAnalytics from "./LogAnalytics";
|
||||
import LogTable from "./LogTable";
|
||||
import MasterTerminal from "./MasterTerminal";
|
||||
import SystemTickerLogs from "./SystemTickerLogs";
|
||||
|
||||
export const metadata = {
|
||||
title: "ThirdEye | System Logs",
|
||||
description: "Advanced System Logs — ThirdEye Sovereign Protocol",
|
||||
};
|
||||
|
||||
export default function LogsPage() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[#09090B] text-white">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-[240px] flex flex-col h-screen overflow-hidden bg-[#09090B] relative">
|
||||
<TopBar />
|
||||
|
||||
{/* Log Content */}
|
||||
<div className="flex-1 p-8 overflow-hidden flex flex-col gap-6">
|
||||
{/* Analytics Top Bar */}
|
||||
<LogAnalytics />
|
||||
|
||||
{/* Main Log Viewer & Console */}
|
||||
<div className="flex-1 flex flex-col gap-4 overflow-hidden">
|
||||
{/* Table Section */}
|
||||
<LogTable />
|
||||
|
||||
{/* Interactive Console */}
|
||||
<MasterTerminal />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Ticker */}
|
||||
<SystemTickerLogs />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
533
thirdeye/dashboard/app/meetings/page.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import TopBar from "../components/TopBar";
|
||||
import {
|
||||
fetchMeetings,
|
||||
fetchMeetingDetail,
|
||||
fetchMeetingTranscript,
|
||||
fetchMeetingSignals,
|
||||
Meeting,
|
||||
MeetingDetail,
|
||||
TranscriptChunk,
|
||||
Signal,
|
||||
formatRelativeTime,
|
||||
getSeverityColor,
|
||||
getSignalIcon,
|
||||
parseMetaList,
|
||||
} from "../lib/api";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString("en-GB", {
|
||||
day: "2-digit", month: "short", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function SignalTypeBadge({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
meet_decision: "#34D399",
|
||||
meet_action_item: "#60A5FA",
|
||||
meet_blocker: "#F87171",
|
||||
meet_risk: "#FBBF24",
|
||||
meet_open_q: "#A78BFA",
|
||||
meet_summary: "#A78BFA",
|
||||
meet_chunk_raw: "#71717A",
|
||||
};
|
||||
const color = colors[type] || "#A78BFA";
|
||||
return (
|
||||
<span
|
||||
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
|
||||
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
|
||||
>
|
||||
{type.replace("meet_", "").replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Meeting List Item ─────────────────────────────────────────────────────────
|
||||
|
||||
function MeetingListItem({
|
||||
meeting,
|
||||
isSelected,
|
||||
onClick,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
}: {
|
||||
meeting: Meeting;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}) {
|
||||
const ts = meeting.started_at || "";
|
||||
if (dateFrom && ts && ts < dateFrom) return null;
|
||||
if (dateTo && ts && ts > dateTo) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-4 py-3 rounded-xl border transition-all duration-200 mb-2 ${
|
||||
isSelected
|
||||
? "border-[#A78BFA]/60 bg-[rgba(167,139,250,0.12)]"
|
||||
: "border-white/5 bg-[rgba(22,16,36,0.5)] hover:border-[#A78BFA]/30 hover:bg-[rgba(167,139,250,0.06)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "rgba(167,139,250,0.15)" }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "16px" }}>
|
||||
video_camera_front
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-semibold text-zinc-200 truncate font-mono-data">
|
||||
{meeting.meeting_id}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-500">
|
||||
{meeting.started_at ? formatDate(meeting.started_at) : "Date unknown"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] font-bold text-[#A78BFA] flex-shrink-0 mt-0.5"
|
||||
>
|
||||
{meeting.signal_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{Object.entries(meeting.types || {})
|
||||
.filter(([t]) => t !== "meet_started" && t !== "meet_chunk_raw")
|
||||
.slice(0, 4)
|
||||
.map(([type, count]) => (
|
||||
<SignalTypeBadge key={type} type={type} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Transcript Panel ──────────────────────────────────────────────────────────
|
||||
|
||||
function TranscriptPanel({ chunks }: { chunks: TranscriptChunk[] }) {
|
||||
if (chunks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
||||
<span className="material-symbols-outlined text-4xl mb-2">mic_off</span>
|
||||
<p className="text-[12px] uppercase tracking-wider">No transcript chunks stored</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{chunks.map((chunk, i) => (
|
||||
<div
|
||||
key={chunk.id || i}
|
||||
className="rounded-xl border border-white/5 bg-[rgba(22,16,36,0.5)] p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center"
|
||||
style={{ background: "rgba(167,139,250,0.2)" }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "12px" }}>
|
||||
record_voice_over
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-[#A78BFA]">
|
||||
{chunk.speaker || "Unknown Speaker"}
|
||||
</span>
|
||||
<span className="text-[9px] text-zinc-600 font-mono-data">
|
||||
CHUNK {String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-600 font-mono-data">
|
||||
{formatDate(chunk.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-zinc-300 leading-relaxed">{chunk.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Signals Panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SignalsPanel({ signals }: { signals: Signal[] }) {
|
||||
const filtered = signals.filter(
|
||||
(s) => !["meet_chunk_raw", "meet_started"].includes(s.metadata?.type || "")
|
||||
);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
||||
<span className="material-symbols-outlined text-4xl mb-2">sensors_off</span>
|
||||
<p className="text-[12px] uppercase tracking-wider">No signals extracted yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((sig, i) => {
|
||||
const meta = sig.metadata;
|
||||
const color = getSeverityColor(meta.severity);
|
||||
const icon = getSignalIcon(meta.type);
|
||||
return (
|
||||
<div
|
||||
key={sig.id || i}
|
||||
className="rounded-xl border border-white/5 bg-[rgba(22,16,36,0.5)] p-4 border-l-[3px]"
|
||||
style={{ borderLeftColor: color }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg" style={{ background: `${color}18` }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "14px", color }}>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
<SignalTypeBadge type={meta.type} />
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-600 font-mono-data flex-shrink-0">
|
||||
{formatRelativeTime(meta.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-zinc-200 leading-relaxed">{meta.summary || sig.document}</p>
|
||||
{meta.raw_quote && meta.raw_quote !== meta.summary && (
|
||||
<p className="text-[11px] text-zinc-500 mt-2 italic border-l-2 border-zinc-700 pl-3">
|
||||
"{meta.raw_quote.slice(0, 200)}"
|
||||
</p>
|
||||
)}
|
||||
{meta.entities && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{parseMetaList(meta.entities).map((e, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="text-[10px] text-[#A78BFA] bg-[rgba(167,139,250,0.1)] px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MeetingsPage() {
|
||||
const [meetings, setMeetings] = useState<Meeting[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<MeetingDetail | null>(null);
|
||||
const [transcript, setTranscript] = useState<TranscriptChunk[]>([]);
|
||||
const [signals, setSignals] = useState<Signal[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<"transcript" | "signals">("signals");
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// Filters
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchMeetings()
|
||||
.then((data) => setMeetings(data))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const selectMeeting = useCallback(async (id: string) => {
|
||||
setSelectedId(id);
|
||||
setDetail(null);
|
||||
setTranscript([]);
|
||||
setSignals([]);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const [det, trans, sigs] = await Promise.all([
|
||||
fetchMeetingDetail(id),
|
||||
fetchMeetingTranscript(id),
|
||||
fetchMeetingSignals(id),
|
||||
]);
|
||||
setDetail(det);
|
||||
setTranscript(trans.transcript);
|
||||
setSignals(sigs);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredMeetings = meetings.filter((m) => {
|
||||
if (search && !m.meeting_id.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (dateFrom && m.started_at && m.started_at < dateFrom) return false;
|
||||
if (dateTo && m.started_at && m.started_at > dateTo) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalSignals = meetings.reduce((sum, m) => sum + m.signal_count, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex" style={{ backgroundColor: "#09090B" }}>
|
||||
<Sidebar />
|
||||
<div className="flex-1 ml-[240px] flex flex-col min-h-screen">
|
||||
<TopBar />
|
||||
<main className="flex-1 p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 animate-fade-in-up opacity-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||
style={{ background: "rgba(167,139,250,0.15)" }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[#A78BFA]">video_camera_front</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white tracking-tight">Meeting History</h1>
|
||||
<p className="text-[11px] text-zinc-500 uppercase tracking-[0.2em]">
|
||||
Google Meet · Signal Intelligence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 animate-fade-in-up opacity-0 delay-100">
|
||||
{[
|
||||
{ label: "Total Meetings", value: meetings.length, icon: "video_camera_front" },
|
||||
{ label: "Total Signals", value: totalSignals, icon: "sensors" },
|
||||
{
|
||||
label: "Avg Signals / Meeting",
|
||||
value: meetings.length ? Math.round(totalSignals / meetings.length) : 0,
|
||||
icon: "analytics",
|
||||
},
|
||||
].map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="neon-card-gradient rounded-xl p-4 border border-white/5 flex items-center gap-4"
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "rgba(167,139,250,0.1)" }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "18px" }}>
|
||||
{stat.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{stat.value}</p>
|
||||
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">{stat.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex items-center gap-3 mb-6 animate-fade-in-up opacity-0 delay-200">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<span
|
||||
className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
|
||||
style={{ fontSize: "16px" }}
|
||||
>
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search meeting ID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-[#141419] border border-white/10 rounded-lg pl-9 pr-4 py-2.5 text-[12px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
||||
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
/>
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
||||
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
||||
/>
|
||||
{(dateFrom || dateTo || search) && (
|
||||
<button
|
||||
onClick={() => { setDateFrom(""); setDateTo(""); setSearch(""); }}
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300 uppercase tracking-wider px-3 py-2 rounded-lg border border-white/10 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-panel layout */}
|
||||
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 400px)" }}>
|
||||
{/* Left: Meeting List */}
|
||||
<div className="w-[320px] flex-shrink-0">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
||||
<div className="w-8 h-8 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin mb-3" />
|
||||
<p className="text-[11px] uppercase tracking-wider">Loading meetings...</p>
|
||||
</div>
|
||||
) : filteredMeetings.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
||||
<span className="material-symbols-outlined text-4xl mb-2">video_camera_front</span>
|
||||
<p className="text-[12px] uppercase tracking-wider">No meetings found</p>
|
||||
<p className="text-[10px] text-zinc-700 mt-1">Meetings appear when Google Meet extension sends data</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="custom-scrollbar overflow-y-auto" style={{ maxHeight: "calc(100vh - 420px)" }}>
|
||||
{filteredMeetings.map((m) => (
|
||||
<MeetingListItem
|
||||
key={m.meeting_id}
|
||||
meeting={m}
|
||||
isSelected={selectedId === m.meeting_id}
|
||||
onClick={() => selectMeeting(m.meeting_id)}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Detail Panel */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{!selectedId ? (
|
||||
<div className="neon-card-gradient rounded-2xl border border-white/5 h-full flex flex-col items-center justify-center text-zinc-600 p-12">
|
||||
<span className="material-symbols-outlined text-5xl mb-4 text-zinc-700">
|
||||
video_camera_front
|
||||
</span>
|
||||
<p className="text-[14px] uppercase tracking-wider text-zinc-500">Select a meeting</p>
|
||||
<p className="text-[11px] text-zinc-700 mt-2">to view transcript and signals</p>
|
||||
</div>
|
||||
) : detailLoading ? (
|
||||
<div className="neon-card-gradient rounded-2xl border border-white/5 h-full flex flex-col items-center justify-center">
|
||||
<div className="w-10 h-10 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin mb-4" />
|
||||
<p className="text-[12px] text-zinc-500 uppercase tracking-wider">Loading meeting data...</p>
|
||||
</div>
|
||||
) : detail ? (
|
||||
<div className="neon-card-gradient rounded-2xl border border-white/5 flex flex-col h-full">
|
||||
{/* Meeting header */}
|
||||
<div className="p-6 border-b border-white/5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] text-[#A78BFA] font-bold uppercase tracking-wider bg-[rgba(167,139,250,0.1)] px-2 py-0.5 rounded-full">
|
||||
MEETING
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-500 font-mono-data">{detail.group_id}</span>
|
||||
</div>
|
||||
<h2 className="text-[15px] font-bold text-white font-mono-data break-all">
|
||||
{detail.meeting_id}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex items-center gap-1 text-zinc-400">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>schedule</span>
|
||||
<span className="text-[11px]">{formatDate(detail.started_at)}</span>
|
||||
</div>
|
||||
{detail.speaker && detail.speaker !== "Unknown" && (
|
||||
<div className="flex items-center gap-1 text-zinc-400">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>person</span>
|
||||
<span className="text-[11px]">{detail.speaker}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-[#A78BFA]">{detail.total_signals}</p>
|
||||
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">signals</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signal type counts */}
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{Object.entries(detail.signal_counts)
|
||||
.filter(([t]) => t !== "meet_started" && t !== "meet_chunk_raw")
|
||||
.map(([type, count]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="flex items-center gap-1.5 bg-[rgba(255,255,255,0.04)] border border-white/5 rounded-lg px-3 py-1"
|
||||
>
|
||||
<SignalTypeBadge type={type} />
|
||||
<span className="text-[11px] font-bold text-zinc-300">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{detail.summary && (
|
||||
<div className="mt-4 p-4 rounded-xl bg-[rgba(167,139,250,0.06)] border border-[#A78BFA]/15">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "14px" }}>
|
||||
summarize
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-[#A78BFA] uppercase tracking-wider">
|
||||
AI Summary
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-zinc-300 leading-relaxed whitespace-pre-line">
|
||||
{detail.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white/5">
|
||||
{(["signals", "transcript"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-6 py-3 text-[11px] font-semibold uppercase tracking-wider transition-colors ${
|
||||
activeTab === tab
|
||||
? "text-[#A78BFA] border-b-2 border-[#A78BFA]"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{tab === "signals" ? `Signals (${signals.filter(s => !["meet_chunk_raw","meet_started"].includes(s.metadata?.type)).length})` : `Transcript (${transcript.length} chunks)`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
|
||||
{activeTab === "signals" ? (
|
||||
<SignalsPanel signals={signals} />
|
||||
) : (
|
||||
<TranscriptPanel chunks={transcript} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
thirdeye/dashboard/app/mission/page.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import TopBar from "../components/TopBar";
|
||||
import {
|
||||
fetchGroups,
|
||||
fetchAllSignals,
|
||||
fetchCrossGroupInsights,
|
||||
Group,
|
||||
Signal,
|
||||
CrossGroupInsight,
|
||||
formatRelativeTime,
|
||||
getSignalIcon,
|
||||
getSeverityColor,
|
||||
parseMetaList,
|
||||
} from "../lib/api";
|
||||
|
||||
function SignalCard({ signal, delay }: { signal: Signal; delay: number }) {
|
||||
const meta = signal.metadata;
|
||||
const icon = getSignalIcon(meta.type);
|
||||
const color = getSeverityColor(meta.severity);
|
||||
const time = formatRelativeTime(meta.timestamp);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="neon-card-gradient rounded-xl p-6 relative border-l-[3px] border-t border-r border-b border-white/5 flex flex-col h-full group hover:brightness-110 hover:-translate-y-1 transition-all duration-300 shadow-md animate-fade-in-up opacity-0"
|
||||
style={{ borderLeftColor: color, animationDelay: `${delay}ms` }}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="p-2.5 bg-[#A78BFA]/10 rounded-lg">
|
||||
<span className="material-symbols-outlined text-xl" style={{ color }}>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-400 uppercase tracking-tighter">{time}</span>
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-zinc-200 leading-relaxed mb-8 flex-1">
|
||||
{signal.document}
|
||||
</p>
|
||||
<div className="mt-auto flex items-center justify-between">
|
||||
<span
|
||||
className="text-[10px] font-bold uppercase tracking-[0.1em]"
|
||||
style={{ color: "#A78BFA" }}
|
||||
>
|
||||
{meta.type.replace(/_/g, " ")}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-zinc-500 group-hover:text-[#A78BFA] transition-all cursor-pointer">
|
||||
arrow_forward
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InsightHeroCard({ insight }: { insight: CrossGroupInsight }) {
|
||||
const groupAName = insight.group_a?.name || insight.group_a?.group_id || "Group A";
|
||||
const groupBName = insight.group_b?.name || insight.group_b?.group_id || "Group B";
|
||||
|
||||
return (
|
||||
<section className="relative neon-card-gradient rounded-2xl border-l-[3px] border-[#A78BFA] primary-glow overflow-hidden animate-fade-in-up opacity-0 delay-200 shadow-2xl">
|
||||
<div className="p-10 grid md:grid-cols-3 gap-10 relative z-10">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="px-3 py-1 bg-[#A78BFA]/20 text-[#A78BFA] text-[10px] font-bold tracking-[0.1em] rounded-full border border-[#A78BFA]/30"
|
||||
>
|
||||
{insight.severity.toUpperCase()}_ALERT
|
||||
</span>
|
||||
<span className="text-zinc-400 text-[11px] uppercase tracking-widest font-semibold">
|
||||
Cross-Group Intelligence Analysis
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white leading-tight">
|
||||
{insight.type.replace(/_/g, " ")}
|
||||
</h2>
|
||||
<p className="text-zinc-300 text-[15px] leading-relaxed max-w-2xl font-light">
|
||||
{insight.description}
|
||||
</p>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button className="px-6 py-3 bg-[#A78BFA] text-background text-[11px] font-bold uppercase tracking-widest rounded-lg hover:opacity-90 transition-all shadow-lg shadow-[#A78BFA]/20">
|
||||
Schedule Sync
|
||||
</button>
|
||||
<button className="px-6 py-3 bg-white/5 border border-white/5 text-zinc-300 text-[11px] font-bold uppercase tracking-widest rounded-lg hover:bg-white/10 transition-all">
|
||||
Dismiss Signal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-black/20 p-5 rounded-xl border border-white/5">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-[11px] text-zinc-300 font-semibold">{groupAName}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400 mt-3 italic font-light tracking-wide">
|
||||
{insight.group_a?.evidence || "Evidence collected"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-black/20 p-5 rounded-xl border border-white/5">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-[11px] text-zinc-300 font-semibold">{groupBName}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400 mt-3 italic font-light tracking-wide">
|
||||
{insight.group_b?.evidence || "Evidence collected"}
|
||||
</p>
|
||||
</div>
|
||||
{insight.recommendation && (
|
||||
<div className="p-5 border border-[#A78BFA]/20 bg-[#A78BFA]/10 rounded-xl">
|
||||
<p className="text-[12px] text-[#A78BFA] font-medium leading-relaxed">
|
||||
<span className="font-bold">RECOMMENDATION:</span> {insight.recommendation}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 top-0 h-full w-96 opacity-10 pointer-events-none bg-gradient-to-l from-[#A78BFA] to-transparent" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MissionDashboard() {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [signals, setSignals] = useState<Signal[]>([]);
|
||||
const [insights, setInsights] = useState<CrossGroupInsight[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [insightsLoading, setInsightsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 12000);
|
||||
|
||||
async function loadCore() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [grps, allGroupSignals] = await Promise.all([
|
||||
fetchGroups(),
|
||||
fetchAllSignals(),
|
||||
]);
|
||||
setGroups(grps);
|
||||
const flat = allGroupSignals
|
||||
.flatMap((g) => g.signals)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.metadata.timestamp).getTime() -
|
||||
new Date(a.metadata.timestamp).getTime()
|
||||
)
|
||||
.slice(0, 16);
|
||||
setSignals(flat);
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name !== "AbortError") {
|
||||
setError("Backend unavailable — check that the API server is running.");
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInsights() {
|
||||
setInsightsLoading(true);
|
||||
try {
|
||||
const cgi = await fetchCrossGroupInsights();
|
||||
setInsights(cgi);
|
||||
} catch {
|
||||
// Non-fatal — insights just won't show
|
||||
} finally {
|
||||
setInsightsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadCore();
|
||||
loadInsights();
|
||||
|
||||
return () => {
|
||||
ctrl.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const totalSignals = groups.reduce((acc, g) => acc + g.signal_count, 0);
|
||||
const criticalSignals = signals.filter(
|
||||
(s) => s.metadata.severity === "critical" || s.metadata.severity === "high"
|
||||
).length;
|
||||
const criticalInsights = insights.filter((i) => i.severity === "critical").length;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full bg-[#09090B] text-white font-['Poppins'] overflow-hidden selection:bg-[#A78BFA]/30 relative">
|
||||
<Sidebar />
|
||||
<TopBar />
|
||||
|
||||
<main className="absolute left-[240px] top-20 right-0 bottom-0 overflow-y-auto custom-scrollbar bg-[#09090B] z-10 flex flex-col">
|
||||
<div className="p-10 space-y-10 animate-fade-in-up opacity-0 delay-100">
|
||||
|
||||
{/* Status Banner */}
|
||||
{error && (
|
||||
<div className="px-5 py-3 rounded-xl border border-yellow-500/20 bg-yellow-500/5 text-yellow-400 text-[11px] font-mono flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-sm">warning</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metric Tiles */}
|
||||
<section className="grid grid-cols-4 gap-6">
|
||||
<div className="neon-card-gradient border border-white/5 p-6 rounded-xl flex flex-col justify-between hover:brightness-110 transition-all shadow-lg animate-fade-in-up opacity-0 delay-100">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.15em] font-semibold text-zinc-400">Monitored Groups</span>
|
||||
<span className="material-symbols-outlined text-[#A78BFA]/60 text-sm">sensors</span>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold text-white">
|
||||
{loading ? "—" : groups.length}
|
||||
</div>
|
||||
<div className="text-[11px] text-[#A78BFA] mt-3 flex items-center gap-1.5 font-medium">
|
||||
<span className="material-symbols-outlined text-[11px]">trending_up</span>
|
||||
<span>Active Streams</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="neon-card-gradient border border-white/5 p-6 rounded-xl flex flex-col justify-between hover:brightness-110 transition-all shadow-lg animate-fade-in-up opacity-0 delay-150">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.15em] font-semibold text-zinc-400">Signals Processed</span>
|
||||
<span className="material-symbols-outlined text-[#A78BFA]/60 text-sm">data_exploration</span>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold text-white">
|
||||
{loading ? "—" : totalSignals >= 1000 ? `${(totalSignals / 1000).toFixed(1)}k` : totalSignals}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400 mt-3 uppercase tracking-tighter">Total Indexed</div>
|
||||
</div>
|
||||
|
||||
<div className="neon-card-gradient border border-white/5 p-6 rounded-xl flex flex-col justify-between hover:brightness-110 transition-all shadow-lg animate-fade-in-up opacity-0 delay-200">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.15em] font-semibold text-zinc-400">Open Insights</span>
|
||||
<span className="material-symbols-outlined text-[#A78BFA]/60 text-sm">lightbulb</span>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold text-white">
|
||||
{loading ? "—" : insights.length}
|
||||
</div>
|
||||
{criticalInsights > 0 && (
|
||||
<div className="text-[11px] text-[#ff6f78] mt-3 font-semibold uppercase tracking-tighter">
|
||||
{criticalInsights} Critical Priority
|
||||
</div>
|
||||
)}
|
||||
{criticalInsights === 0 && !loading && (
|
||||
<div className="text-[11px] text-zinc-400 mt-3 uppercase tracking-tighter">All Clear</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="neon-card-gradient border border-white/5 p-6 rounded-xl flex flex-col justify-between hover:brightness-110 transition-all shadow-lg animate-fade-in-up opacity-0 delay-300">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.15em] font-semibold text-zinc-400">High Priority</span>
|
||||
<span className="material-symbols-outlined text-[#A78BFA]/60 text-sm">priority_high</span>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold text-white">
|
||||
{loading ? "—" : criticalSignals}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400 mt-3 uppercase tracking-tighter">
|
||||
{criticalSignals > 0 ? "Needs Attention" : "Optimal Range"}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Insight */}
|
||||
{insightsLoading && !loading && (
|
||||
<section className="relative neon-card-gradient rounded-2xl border border-white/5 p-10 flex items-center gap-3 text-zinc-600 animate-fade-in-up opacity-0 delay-200">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">autorenew</span>
|
||||
<span className="text-[11px] uppercase tracking-widest">Analysing cross-group patterns...</span>
|
||||
</section>
|
||||
)}
|
||||
{!insightsLoading && insights.length > 0 && (
|
||||
<InsightHeroCard insight={insights[0]} />
|
||||
)}
|
||||
{!insightsLoading && insights.length === 0 && groups.length >= 2 && (
|
||||
<section className="relative neon-card-gradient rounded-2xl border border-white/5 p-10 text-center animate-fade-in-up opacity-0 delay-200">
|
||||
<span className="material-symbols-outlined text-[#A78BFA] text-4xl mb-4 block">hub</span>
|
||||
<p className="text-zinc-400 text-sm">No cross-group insights yet. Signals are accumulating...</p>
|
||||
</section>
|
||||
)}
|
||||
{!loading && groups.length < 2 && (
|
||||
<section className="relative neon-card-gradient rounded-2xl border border-white/5 p-10 text-center animate-fade-in-up opacity-0 delay-200">
|
||||
<span className="material-symbols-outlined text-zinc-600 text-4xl mb-4 block">hub</span>
|
||||
<p className="text-zinc-500 text-sm">Cross-group analysis requires at least 2 monitored groups.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Live Signals Stream */}
|
||||
<section className="space-y-6 animate-fade-in-up opacity-0 delay-300 mb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[11px] uppercase tracking-[0.25em] font-bold text-zinc-500 flex items-center gap-3">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#A78BFA] primary-glow" />
|
||||
Live Signals Stream
|
||||
{!loading && (
|
||||
<span className="text-zinc-700 normal-case tracking-normal font-normal">
|
||||
({signals.length} signals)
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex gap-4">
|
||||
<span className="material-symbols-outlined text-zinc-600 text-lg cursor-pointer hover:text-[#A78BFA] transition-colors">filter_list</span>
|
||||
<span className="material-symbols-outlined text-zinc-600 text-lg cursor-pointer hover:text-[#A78BFA] transition-colors">sort</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20 text-zinc-600">
|
||||
<span className="material-symbols-outlined animate-spin mr-3">autorenew</span>
|
||||
Loading signals...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && signals.length === 0 && (
|
||||
<div className="text-center py-20 text-zinc-600">
|
||||
<span className="material-symbols-outlined text-4xl mb-3 block">inbox</span>
|
||||
No signals yet. Connect Telegram groups to start receiving intelligence.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && signals.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pb-12">
|
||||
{signals.map((signal, idx) => (
|
||||
<SignalCard key={signal.id} signal={signal} delay={idx * 50} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Intelligence Ticker */}
|
||||
<footer className="bg-[#0C0C0E] p-4 rounded-xl border border-white/5 overflow-hidden shadow-lg mb-8 animate-fade-in-up opacity-0 delay-400">
|
||||
<div className="flex items-center gap-6 whitespace-nowrap overflow-hidden">
|
||||
<span className="text-[10px] font-extrabold text-[#A78BFA] uppercase tracking-widest bg-[#A78BFA]/10 px-3 py-1 rounded-full border border-[#A78BFA]/20 z-10 relative shadow-[0_0_15px_rgba(167,139,250,0.2)]">
|
||||
System_Log
|
||||
</span>
|
||||
<div className="flex gap-12 text-[10px] font-medium text-zinc-500 uppercase tracking-wide opacity-80 ticker-track">
|
||||
<span>[Signal_Rcv] :: Groups={groups.length} :: Status=Active</span>
|
||||
<span>[Signal_Count] :: Total={totalSignals} :: Indexed</span>
|
||||
<span>[Insight_Engine] :: CrossGroup_Analysis_Running</span>
|
||||
<span>[Health] :: API=Online :: DB=Connected</span>
|
||||
<span>[Signal_Rcv] :: Groups={groups.length} :: Status=Active</span>
|
||||
<span>[Signal_Count] :: Total={totalSignals} :: Indexed</span>
|
||||
<span>[Insight_Engine] :: CrossGroup_Analysis_Running</span>
|
||||
<span>[Health] :: API=Online :: DB=Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
514
thirdeye/dashboard/app/page.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="bg-[#09090B] text-white font-poppins">
|
||||
{/* TopNavBar */}
|
||||
<nav className="fixed top-0 w-full z-50 pt-8 flex justify-center pointer-events-none">
|
||||
<div className="flex justify-between items-center max-w-[90rem] w-full mx-auto px-8">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-3 pointer-events-auto w-[250px] hover:opacity-80 transition-opacity drop-shadow-lg">
|
||||
<Image src="/new-logo.png" alt="ThirdEye" width={32} height={32} className="rounded-md" />
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="text-2xl font-black tracking-wide text-[#a88cf8] uppercase leading-none mt-1">THIRDEYE</div>
|
||||
<div className="text-[9px] font-bold tracking-[0.3em] text-zinc-500 mt-1.5 uppercase">SOVEREIGN_V1</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Center Pill Navigation */}
|
||||
<div className="hidden md:flex items-center gap-8 px-8 py-3 rounded-full border border-white/10 bg-[#09090B]/60 backdrop-blur-xl pointer-events-auto shadow-[0_10px_30px_rgba(0,0,0,0.5)]">
|
||||
<Link href="/intelligence" className="text-sm font-medium text-white/70 hover:text-white transition-colors tracking-wide">Intelligence</Link>
|
||||
<Link href="/agents" className="text-sm font-medium text-white/70 hover:text-white transition-colors tracking-wide">Agents</Link>
|
||||
<Link href="/knowledge-base" className="text-sm font-medium text-white/70 hover:text-white transition-colors tracking-wide">Security</Link>
|
||||
<Link href="/logs" className="text-sm font-medium text-white/70 hover:text-white transition-colors tracking-wide">Infrastructure</Link>
|
||||
<Link href="#pricing" className="text-sm font-medium text-white/70 hover:text-white transition-colors tracking-wide">Pricing</Link>
|
||||
</div>
|
||||
|
||||
{/* Right Actions */}
|
||||
<div className="flex items-center justify-end pointer-events-auto w-[200px]">
|
||||
<Link href="/mission" className="bg-primary/10 hover:bg-primary/20 text-white/90 px-5 py-2.5 rounded-full text-sm font-semibold transition-colors border border-primary/20 tracking-wide shadow-[0_0_15px_rgba(167,139,250,0.15)]">
|
||||
Launch Control
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="relative overflow-hidden">
|
||||
{/* Fixed Futuristic Background */}
|
||||
<div className="fixed inset-0 z-0 pointer-events-none tech-bg-base">
|
||||
<div className="absolute inset-0 tech-grid"></div>
|
||||
<div className="absolute inset-0 tech-lights opacity-50"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#09090B]/50 to-[#09090B] pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-32 pb-0 px-8 z-10 min-h-screen flex flex-col items-center justify-start overflow-hidden" id="hero">
|
||||
|
||||
{/* Fading Background concentric circles */}
|
||||
<div className="absolute top-[40%] left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-[800px] -z-10 mask-radial-fade">
|
||||
<div className="orbit-ring w-[40vw] h-[40vw] max-w-[600px] max-h-[600px]"></div>
|
||||
<div className="orbit-ring w-[60vw] h-[60vw] max-w-[800px] max-h-[800px]"></div>
|
||||
<div className="orbit-ring w-[80vw] h-[80vw] max-w-[1000px] max-h-[1000px]"></div>
|
||||
</div>
|
||||
|
||||
{/* Header Content */}
|
||||
<div className="max-w-6xl mx-auto text-center relative z-20 mt-4">
|
||||
{/* Pill Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6 }}
|
||||
className="inline-flex items-center gap-2 px-5 py-2 rounded-full border border-white/10 bg-white/[0.03] backdrop-blur-md mb-6 hover:bg-white/[0.05] transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-white">✨</span>
|
||||
<span className="text-xs font-semibold text-white/90 tracking-wide">New: ThirdEye Core v2.4 Active</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.7, delay: 0.1 }}
|
||||
className="text-6xl md:text-8xl lg:text-[7rem] font-semibold tracking-tight text-white mb-6 leading-tight"
|
||||
>
|
||||
Think better with <br className="hidden md:block" />ThirdEye
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="text-xl md:text-2xl lg:text-3xl text-on-surface-variant font-medium mb-10 max-w-3xl mx-auto leading-relaxed"
|
||||
>
|
||||
Never miss a note, idea, or connection within your own hardened infrastructure.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Black Hole & Flare Layer */}
|
||||
<div className="relative w-full flex justify-center mt-4 md:mt-8 h-[280px] pointer-events-none z-10">
|
||||
{/* Horizontal flares */}
|
||||
<div className="hero-beam"></div>
|
||||
<div className="hero-beam-core"></div>
|
||||
|
||||
{/* The Ring */}
|
||||
<div className="black-hole-ring w-[220px] h-[220px] md:w-[320px] md:h-[320px]"></div>
|
||||
<div className="absolute top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-[#a855f7]/20 rounded-full blur-[80px] -z-10"></div>
|
||||
</div>
|
||||
|
||||
{/* Mock UI Container */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="w-full max-w-6xl mx-auto -mt-[140px] md:-mt-[180px] relative z-20 px-4 pb-20"
|
||||
>
|
||||
<div
|
||||
className="w-full aspect-[16/9] bg-[#0c0a15]/80 backdrop-blur-xl rounded-[1rem] border border-white/10 shadow-[0_40px_100px_rgba(167,139,250,0.15)] overflow-hidden flex flex-col relative"
|
||||
style={{ WebkitMaskImage: 'linear-gradient(to bottom, black 60%, transparent 100%)', maskImage: 'linear-gradient(to bottom, black 60%, transparent 100%)' }}
|
||||
>
|
||||
{/* Fake UI Header */}
|
||||
<div className="h-12 border-b border-white/5 flex items-center px-4 gap-4 w-full bg-[#12101c]/90">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-white/20"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-white/20"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-white/20"></div>
|
||||
</div>
|
||||
<div className="h-5 w-48 bg-white/5 rounded mx-2"></div>
|
||||
<div className="ml-auto h-5 w-8 bg-white/5 rounded"></div>
|
||||
</div>
|
||||
{/* Fake UI Body */}
|
||||
<div className="flex-1 flex w-full h-full text-left overflow-hidden">
|
||||
{/* Sidebar - File Tree */}
|
||||
<div className="w-56 border-r border-white/5 p-4 flex flex-col gap-1 font-mono text-xs text-zinc-400 bg-black/20">
|
||||
<div className="text-zinc-500 font-bold mb-3 uppercase tracking-widest text-[10px] pl-2 mt-2">Explorer</div>
|
||||
<div className="flex items-center gap-3 text-primary bg-primary/10 px-3 py-2 rounded-md"><span className="material-symbols-outlined text-[16px]">description</span> thirdeye.ts</div>
|
||||
<div className="flex items-center gap-3 hover:bg-white/5 px-3 py-2 rounded-md cursor-pointer transition-colors"><span className="material-symbols-outlined text-[16px]">shield</span> vault.config.json</div>
|
||||
<div className="flex items-center gap-3 hover:bg-white/5 px-3 py-2 rounded-md cursor-pointer transition-colors"><span className="material-symbols-outlined text-[16px]">api</span> neural_routes.ts</div>
|
||||
<div className="flex items-center gap-3 hover:bg-white/5 px-3 py-2 rounded-md cursor-pointer transition-colors"><span className="material-symbols-outlined text-[16px]">database</span> vector_store.ts</div>
|
||||
</div>
|
||||
|
||||
{/* Content - Code Editor */}
|
||||
<div className="flex-1 flex flex-col bg-[#0d0914]/60 relative">
|
||||
<div className="w-full h-10 bg-black/40 border-b border-white/5 flex items-center px-4 shrink-0">
|
||||
<div className="text-xs font-mono text-zinc-300 flex items-center gap-3 bg-white/5 px-4 py-1.5 rounded-t-lg border-b-2 border-primary">
|
||||
<span className="text-primary tracking-widest">thirdeye.ts</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white/20"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-6 overflow-hidden relative">
|
||||
{/* Line numbers area */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-12 bg-black/20 border-r border-white/5 pt-6 pb-6 flex flex-col items-end pr-3 font-mono text-xs text-zinc-600 select-none space-y-[0.4rem]">
|
||||
{[...Array(15)].map((_, i) => <div key={i}>{i + 1}</div>)}
|
||||
</div>
|
||||
{/* Code */}
|
||||
<pre className="text-[13px] font-mono text-zinc-300 leading-relaxed ml-10 pl-4 whitespace-pre">
|
||||
<span className="text-pink-400">import</span> {'{'} ThirdEyeAgent, VaultConnection {'}'} <span className="text-pink-400">from</span> <span className="text-emerald-300">'@thirdeye/core'</span>;<br/><br/>
|
||||
<span className="text-slate-500 opacity-60">// Initialize secure intelligence node</span><br/>
|
||||
<span className="text-pink-400">const</span> edgeNode = <span className="text-pink-400">new</span> <span className="text-yellow-200">ThirdEyeAgent</span>({'{'}<br/>
|
||||
model: <span className="text-emerald-300">'neural-synth-v4'</span>,<br/>
|
||||
securityLevel: <span className="text-orange-300">9</span>,<br/>
|
||||
airGapped: <span className="text-indigo-300">true</span><br/>
|
||||
{'}'});<br/><br/>
|
||||
<span className="text-pink-400">async function</span> <span className="text-blue-300">deployIntelligence</span>() {'{'}<br/>
|
||||
<span className="text-pink-400">await</span> VaultConnection.<span className="text-yellow-200">establish</span>();<br/>
|
||||
<span className="text-pink-400">const</span> insights = <span className="text-pink-400">await</span> edgeNode.<span className="text-yellow-200">processStream</span>({'{'}<br/>
|
||||
source: <span className="text-emerald-300">'internal-comms'</span>,<br/>
|
||||
realtime: <span className="text-indigo-300">true</span><br/>
|
||||
{'}'});<br/>
|
||||
<span className="text-indigo-300">console</span>.<span className="text-yellow-200">log</span>(<span className="text-emerald-300">'[SYSTEM] Intelligence active: '</span>, insights);<br/>
|
||||
{'}'}<br/><br/>
|
||||
<span className="text-yellow-200">deployIntelligence</span>();
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Terminal Output */}
|
||||
<div className="w-72 p-5 flex flex-col bg-black/60 border-l border-white/5 font-mono text-[11px] leading-loose">
|
||||
<div className="flex items-center justify-between mb-4 border-b border-white/10 pb-3">
|
||||
<div className="text-zinc-500 font-bold uppercase tracking-widest text-[10px]">Terminal</div>
|
||||
<div className="flex gap-2 text-zinc-600">
|
||||
<span className="w-2.5 h-2.5 rounded-full border border-zinc-500 cursor-pointer hover:border-white"></span>
|
||||
<span className="w-2.5 h-2.5 rounded-full border border-zinc-500 cursor-pointer hover:border-white"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-zinc-400 truncate"><span className="text-emerald-400 mr-2">➜</span><span className="text-cyan-400">~</span> pnpm run deploy</div>
|
||||
<div className="text-zinc-500 mt-2 truncate">[14:02:01] Starting ThirdEye v2.4...</div>
|
||||
<div className="text-zinc-500 truncate">[14:02:02] Establishing Vault... <span className="text-emerald-400 ml-1">OK</span></div>
|
||||
<div className="text-zinc-500 truncate">[14:02:03] Binding to Edge Nodes... <span className="text-emerald-400 ml-1">OK</span></div>
|
||||
<div className="text-amber-400/80 mt-2 bg-amber-400/10 px-2 border-l border-amber-400 text-[10px] leading-relaxed">WARN: Semantic drift detected in Node 4. Compensating...</div>
|
||||
<div className="text-primary mt-4 flex items-center gap-2 animate-pulse bg-primary/10 px-2 py-1 rounded w-max">
|
||||
<span className="w-2 h-2 bg-primary rounded-full block shadow-[0_0_8px_rgba(167,139,250,1)]"></span> <span className="font-semibold text-[10px]">Awaiting stream</span>
|
||||
</div>
|
||||
|
||||
{/* Visual grid representing active nodes */}
|
||||
<div className="mt-auto mb-2 text-zinc-500 font-bold uppercase tracking-widest text-[9px] flex items-center gap-2">
|
||||
Node Matrix <span className="w-full h-px bg-white/5 flex-1"></span>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 xl:grid-cols-8 gap-1.5 mt-2">
|
||||
{[...Array(24)].map((_, i) => (
|
||||
<div key={i} className={`aspect-square rounded-[2px] ${i === 12 || i === 18 ? 'bg-primary animate-pulse shadow-[0_0_5px_rgba(167,139,250,0.8)]' : i % 7 === 0 ? 'bg-emerald-400/60' : 'bg-white/5'}`}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* The Lens (Architecture) */}
|
||||
<section className="pt-48 pb-32 px-8 relative z-10" id="architecture">
|
||||
{/* Smooth Section Transition & Grid Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#050408] to-transparent -z-10"></div>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:48px_48px] [mask-image:linear-gradient(to_bottom,transparent_0%,black_20%,black_80%,transparent_100%)] -z-10"></div>
|
||||
<div className="max-w-[85rem] mx-auto relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-100px" }} transition={{ duration: 0.7 }}
|
||||
className="mb-20"
|
||||
>
|
||||
<h2 className="text-zinc-500 font-mono text-[11px] tracking-[0.3em] uppercase mb-4 font-bold">The Lens</h2>
|
||||
<h3 className="text-4xl md:text-5xl font-black text-white/90 tracking-tight">Technical Architecture</h3>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ duration: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 border-l border-t border-white/[0.05]"
|
||||
>
|
||||
{[
|
||||
{ icon: "speed", title: "Built for throughput", desc: "Instantly sync agent telemetry across local nodes." },
|
||||
{ icon: "hub", title: "Networked nodes", desc: "Form a robust graph of capabilities with semantic routing." },
|
||||
{ icon: "memory", title: "Edge deployment", desc: "Run natively on endpoint devices, online or air-gapped." },
|
||||
{ icon: "lock", title: "End-to-end encryption", desc: "Only you and authorized personnel can access military-grade Vaults." },
|
||||
{ icon: "event", title: "Orchestration timeline", desc: "Keep track of active multi-agent cycles and agendas." },
|
||||
{ icon: "wifi_tethering", title: "Live broadcasting", desc: "Push logic updates to all running swarms with one click." },
|
||||
{ icon: "save", title: "Instant snapshots", desc: "Save contextual states from ongoing browser or API sessions." },
|
||||
{ icon: "search", title: "Frictionless recall", desc: "Easily retrieve and index past agent decisions at lightning speed." }
|
||||
].map((item, i) => (
|
||||
<div key={i} className="border-r border-b border-white/[0.05] p-10 hover:bg-white/[0.02] transition-colors flex flex-col justify-start">
|
||||
<div className="w-10 h-10 rounded-lg bg-white/[0.02] border border-white/10 flex items-center justify-center mb-10 shadow-sm">
|
||||
<span className="material-symbols-outlined text-zinc-300 text-[20px]">{item.icon}</span>
|
||||
</div>
|
||||
<h4 className="text-[15px] font-semibold text-white/90 mb-3 tracking-wide">{item.title}</h4>
|
||||
<p className="text-[14px] text-zinc-500/90 leading-relaxed font-medium">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission Control Dashboard Preview */}
|
||||
<section className="py-32 px-8 overflow-hidden relative z-10" id="dashboard">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98, y: 40 }} whileInView={{ opacity: 1, scale: 1, y: 0 }} viewport={{ once: true, margin: "-100px" }} transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className="w-full bg-[#121115] rounded-xl border border-violet-500/20 shadow-[0_30px_100px_rgba(0,0,0,0.9),0_0_80px_rgba(167,139,250,0.25)] overflow-hidden relative ring-1 ring-violet-500/10"
|
||||
>
|
||||
{/* Top Bar */}
|
||||
<div className="h-12 border-b border-[#2a2833] flex items-center px-4 gap-2">
|
||||
<div className="flex gap-1.5 opacity-80 pl-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/80"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-amber-500/80"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/80"></div>
|
||||
</div>
|
||||
<div className="ml-6 font-mono text-[10px] text-zinc-400 tracking-widest font-bold">THIRDEYE_ADMIN_DASHBOARD_V4.0</div>
|
||||
</div>
|
||||
|
||||
{/* Grid content */}
|
||||
<div className="p-6 md:p-8 grid grid-cols-1 lg:grid-cols-12 gap-6 relative bg-[radial-gradient(#ffffff04_1px,transparent_1px)] [background-size:24px_24px]">
|
||||
|
||||
<div className="lg:col-span-8 flex flex-col gap-6 relative z-10">
|
||||
{/* Main Chart */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: 0.2 }}
|
||||
className="h-[280px] bg-[#0c0b0f] rounded-xl border border-[#2a2833] p-8 flex flex-col relative shadow-inner shadow-white/[0.01]"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h5 className="font-bold text-white uppercase tracking-[0.2em] text-xs">Global Intelligence Stream</h5>
|
||||
<div className="text-[10px] font-mono text-zinc-400 border border-[#333] bg-[#1a1820] px-3 py-1.5 rounded">LIVE UPDATES</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-end justify-between gap-1.5 md:gap-3 px-2">
|
||||
{[40, 65, 25, 60, 45, 100, 35, 60, 40].map((h, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col justify-end items-center group relative h-full">
|
||||
<div
|
||||
className={`w-full rounded-t-sm transition-all duration-300 ${i === 5 ? 'bg-[#9d8bbd] shadow-[0_0_15px_rgba(157,139,189,0.2)]' : 'bg-[#4a4657]'}`}
|
||||
style={{ height: `${h}%` }}
|
||||
></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{[
|
||||
{ label: "NODES_ONLINE", value: "1,204" },
|
||||
{ label: "THREAT_MITIGATION", value: "99.9%", glow: true },
|
||||
{ label: "LATENCY_MS", value: "0.42" }
|
||||
].map((stat, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 15 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: 0.3 + (i * 0.1) }}
|
||||
className={`bg-[#0c0b0f] p-6 rounded-xl border border-[#2a2833] flex flex-col justify-between shadow-inner shadow-white/[0.01]`}
|
||||
>
|
||||
<div className="text-[10px] text-zinc-500 font-mono tracking-widest mb-6 uppercase">{stat.label}</div>
|
||||
<div className={`text-4xl font-black font-sans tracking-tight ${stat.glow ? 'text-[#c0aaf2]' : 'text-white'}`}>{stat.value}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay: 0.4 }}
|
||||
className="lg:col-span-4 bg-[#0c0b0f] p-8 rounded-xl border border-[#2a2833] shadow-inner shadow-white/[0.01] flex flex-col relative z-10"
|
||||
>
|
||||
<div className="mb-10 pb-4">
|
||||
<h5 className="font-bold text-white uppercase tracking-[0.2em] text-xs">Mission Log</h5>
|
||||
</div>
|
||||
<div className="space-y-8 font-mono text-[11px] leading-relaxed flex-1 overflow-auto opacity-90 pr-2">
|
||||
{[
|
||||
{ time: "12:45", msg: "Agent_Alpha: Summarizing internal memo cluster 4...", type: "normal" },
|
||||
{ time: "12:46", msg: "Encryption check complete: All tunnels secure.", type: "normal" },
|
||||
{ time: "12:48", msg: "ALERT: New semantic trend identified in Sales.", type: "alert" },
|
||||
{ time: "12:50", msg: "Syncing with ThirdEye Vault...", type: "normal" },
|
||||
{ time: "12:52", msg: "Agent_Gamma: Optimizing inference pathways.", type: "normal" },
|
||||
{ time: "12:55", msg: "Node_Delta: Status green. Processing...", type: "normal" }
|
||||
].map((log, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -10 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay: 0.5 + (i * 0.05) }}
|
||||
className={`flex gap-4 items-start`}
|
||||
>
|
||||
<span className="text-zinc-500 shrink-0">[{log.time}]</span>
|
||||
<span className={`${
|
||||
log.type === 'alert' ? 'text-[#c0aaf2] font-bold' : 'text-zinc-300'
|
||||
}`}>{log.msg}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Integrations (Infrastructure replacement) */}
|
||||
<section className="py-40 px-8 relative z-10" id="integrations">
|
||||
<div className="max-w-[85rem] mx-auto flex flex-col items-center">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-center text-center opacity-90 mb-32">
|
||||
<div className="px-5 py-1.5 rounded-full border border-[#a88cf8]/30 bg-[#a88cf8]/5 text-[11px] font-mono tracking-widest text-[#a88cf8] mb-8 shadow-[0_0_20px_rgba(168,140,248,0.15)]">
|
||||
Integrations
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-semibold text-white tracking-tight leading-tight">
|
||||
Connect ThirdEye <br className="hidden md:block"/>to your infrastructure
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Central Hub Layout */}
|
||||
<div className="relative w-full max-w-5xl flex flex-col md:flex-row items-center justify-between">
|
||||
|
||||
{/* Vertical line mapping and central orb */}
|
||||
<div className="absolute left-1/2 top-[10%] bottom-[10%] -translate-x-1/2 w-[1px] bg-gradient-to-b from-transparent via-[#a88cf8]/30 to-transparent z-0 hidden md:block"></div>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 hidden md:flex items-center justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-[#120F1D] border border-[#a88cf8]/20 shadow-[0_0_60px_rgba(168,140,248,0.25)] relative flex items-center justify-center">
|
||||
{/* Inner glow */}
|
||||
<div className="w-8 h-8 rounded-full bg-[#a88cf8] blur-[15px] opacity-40"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Left Column */}
|
||||
<div className="flex flex-col gap-24 w-full md:w-[40%] items-center relative z-20">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 bg-[#0A0A0C] border border-white/[0.08] shadow-lg rounded-2xl flex items-center justify-center mb-6 shadow-[#000]/50">
|
||||
<span className="material-symbols-outlined text-zinc-300 text-3xl">database</span>
|
||||
</div>
|
||||
<h4 className="text-[17px] font-semibold text-zinc-200 mb-3">SQL & NoSQL</h4>
|
||||
<p className="text-[14px] text-zinc-500 leading-relaxed max-w-[280px]">Securely index structured data from Postgres, MySQL, and MongoDB locally.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 bg-[#0A0A0C] border border-white/[0.08] shadow-lg rounded-2xl flex items-center justify-center mb-6 shadow-[#000]/50">
|
||||
<span className="material-symbols-outlined text-zinc-300 text-3xl">terminal</span>
|
||||
</div>
|
||||
<h4 className="text-[17px] font-semibold text-zinc-200 mb-3">Git Providers</h4>
|
||||
<p className="text-[14px] text-zinc-500 leading-relaxed max-w-[280px]">Scan repositories and track PRs to give agents full codebase context.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="flex flex-col gap-24 w-full md:w-[40%] items-center relative z-20 mt-24 md:mt-0">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 bg-[#0A0A0C] border border-white/[0.08] shadow-lg rounded-2xl flex items-center justify-center mb-6 shadow-[#000]/50">
|
||||
<span className="material-symbols-outlined text-zinc-300 text-3xl">forum</span>
|
||||
</div>
|
||||
<h4 className="text-[17px] font-semibold text-zinc-200 mb-3">Communication Streams</h4>
|
||||
<p className="text-[14px] text-zinc-500 leading-relaxed max-w-[280px]">Ingest continuous organizational communication from Slack and Teams safely.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 bg-[#0A0A0C] border border-white/[0.08] shadow-lg rounded-2xl flex items-center justify-center mb-6 shadow-[#000]/50">
|
||||
<span className="material-symbols-outlined text-zinc-300 text-3xl">cloud</span>
|
||||
</div>
|
||||
<h4 className="text-[17px] font-semibold text-zinc-200 mb-3">Cloud Storage</h4>
|
||||
<p className="text-[14px] text-zinc-500 leading-relaxed max-w-[280px]">Direct VPC peering for your high-volume AWS and Azure storage buckets.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing (Intelligence Fleet) */}
|
||||
<section className="py-40 px-8 relative z-10" id="pricing">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.6 }}
|
||||
className="text-center mb-20 flex flex-col items-center"
|
||||
>
|
||||
<div className="px-5 py-1.5 rounded-full border border-[#a88cf8]/30 bg-[#a88cf8]/5 text-[11px] font-mono tracking-widest text-[#a88cf8] mb-6 shadow-[0_0_20px_rgba(168,140,248,0.15)] uppercase">
|
||||
Fleet Access
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-bold text-white tracking-tight">Intelligence Fleet</h3>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{/* Sentry */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="bg-[#050505] border border-white/[0.05] p-10 rounded-3xl flex flex-col transition-all duration-500 hover:border-white/10 hover:bg-[#080808]"
|
||||
>
|
||||
<h4 className="text-[18px] font-semibold text-zinc-100 mb-2">Sentry</h4>
|
||||
<div className="text-4xl font-bold text-white mb-8">$2,490<span className="text-sm font-medium text-zinc-500 ml-1">/mo</span></div>
|
||||
<ul className="space-y-4 mb-12 flex-grow">
|
||||
{["10 Specialized Agents", "50k Inference Tokens/Day", "Cloud Isolation"].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-zinc-400 text-[14px]">
|
||||
<span className="material-symbols-outlined text-white/20 text-[16px]">check</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="w-full py-3.5 rounded-xl border border-white/10 text-zinc-300 font-semibold text-[14px] hover:bg-white/[0.04] hover:text-white transition-all">Select Fleet</button>
|
||||
</motion.div>
|
||||
|
||||
{/* Commander */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="bg-[#0b0814] border border-[#a88cf8]/40 p-10 rounded-3xl relative flex flex-col z-10 transition-all duration-500 shadow-[0_0_80px_rgba(168,140,248,0.12)] md:-mt-4 md:mb-4"
|
||||
>
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-[#1A1528] border border-[#a88cf8]/50 text-[#a88cf8] px-4 py-1 rounded-full text-[9px] font-mono font-bold uppercase tracking-[0.2em] shadow-[0_0_20px_rgba(168,140,248,0.2)]">Recommended</div>
|
||||
<h4 className="text-[18px] font-semibold text-[#a88cf8] mb-2">Commander</h4>
|
||||
<div className="text-4xl font-bold text-white mb-8">$8,900<span className="text-sm font-medium text-zinc-500 ml-1">/mo</span></div>
|
||||
<ul className="space-y-4 mb-12 flex-grow">
|
||||
{["50 Specialized Agents", "500k Inference Tokens/Day", "On-Prem Hybrid Deployment", "24/7 Priority Support"].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-zinc-200 text-[14px]">
|
||||
<span className="material-symbols-outlined text-[#a88cf8] text-[16px]">check</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="w-full py-3.5 rounded-xl bg-[#a88cf8] text-[#050505] font-bold text-[14px] shadow-[0_0_30px_rgba(168,140,248,0.25)] hover:shadow-[0_0_40px_rgba(168,140,248,0.4)] transition-all">Initiate Fleet</button>
|
||||
</motion.div>
|
||||
|
||||
{/* Enterprise */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="bg-[#050505] border border-white/[0.05] p-10 rounded-3xl flex flex-col transition-all duration-500 hover:border-white/10 hover:bg-[#080808]"
|
||||
>
|
||||
<h4 className="text-[18px] font-semibold text-zinc-100 mb-2">Enterprise</h4>
|
||||
<div className="text-4xl font-bold text-white mb-8">Custom</div>
|
||||
<ul className="space-y-4 mb-12 flex-grow">
|
||||
{["Unlimited Agents", "Dedicated Compute Fleet", "Full Air-Gapped Install"].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-zinc-400 text-[14px]">
|
||||
<span className="material-symbols-outlined text-white/20 text-[16px]">check</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="w-full py-3.5 rounded-xl border border-white/10 text-zinc-300 font-semibold text-[14px] hover:bg-white/[0.04] hover:text-white transition-all">Contact Command</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="py-40 px-8 relative z-10" id="cta">
|
||||
<div className="absolute inset-0 bg-primary-container/10 -z-10 blur-[180px] alive-glow"></div>
|
||||
<div className="max-w-5xl mx-auto text-center">
|
||||
<h2 className="text-6xl md:text-8xl font-black text-white tracking-tighter mb-10 leading-[1.1]">Assemble your<br />intelligence fleet</h2>
|
||||
<p className="text-on-surface-variant/90 text-xl mb-16 max-w-2xl mx-auto leading-relaxed">The era of generic AI is over. Deploy a secure, local intelligence layer that actually understands your organization.</p>
|
||||
<Link href="/mission" className="inline-block px-16 py-8 bg-primary-container text-on-primary-container text-2xl font-black rounded-3xl shadow-[0_0_80px_rgba(167,139,250,0.4)] hover:scale-105 active:scale-95 transition-all duration-500">
|
||||
Initiate Deployment
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-zinc-950/80 backdrop-blur-3xl w-full py-20 border-t border-white/5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 max-w-7xl mx-auto px-8">
|
||||
<div className="col-span-1 md:col-span-1">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Image src="/new-logo.png" alt="ThirdEye" width={32} height={32} className="rounded-md" />
|
||||
<div className="text-2xl font-black text-white">THIRDEYE</div>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-500 leading-loose">© 2024 THIRDEYE.<br />ALL SYSTEMS OPERATIONAL.</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-white font-bold text-sm mb-2">Technical</span>
|
||||
<Link href="/logs" className="font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-500 hover:text-primary transition-colors font-bold">Documentation</Link>
|
||||
<Link href="/intelligence" className="font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-500 hover:text-primary transition-colors font-bold">API Reference</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-white font-bold text-sm mb-2">Company</span>
|
||||
<Link href="/mission" className="font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-500 hover:text-primary transition-colors font-bold">About</Link>
|
||||
<Link href="/knowledge-base" className="font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-500 hover:text-primary transition-colors font-bold">Security</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-white font-bold text-sm mb-2">Legal</span>
|
||||
<a href="#" className="font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-500 hover:text-primary transition-colors font-bold">Status</a>
|
||||
<a href="#" className="font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-500 hover:text-primary transition-colors font-bold">Privacy</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
thirdeye/dashboard/next.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: "http://localhost:8000/api/:path*",
|
||||
},
|
||||
{
|
||||
source: "/health",
|
||||
destination: "http://localhost:8000/health",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
1707
thirdeye/dashboard/package-lock.json
generated
Normal file
24
thirdeye/dashboard/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.38.0",
|
||||
"next": "16.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
thirdeye/dashboard/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
thirdeye/dashboard/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
thirdeye/dashboard/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
thirdeye/dashboard/public/new-logo.png
Normal file
|
After Width: | Height: | Size: 697 KiB |
1
thirdeye/dashboard/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
14
thirdeye/dashboard/public/thirdeye-logo.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 28C22.6274 28 28 22.6274 28 16C28 9.37258 22.6274 4 16 4C9.37258 4 4 9.37258 4 16C4 22.6274 9.37258 28 16 28Z" stroke="url(#paint0_linear)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M19.464 6.34314L12.5359 25.6568" stroke="url(#paint0_linear)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.34299 12.5359L25.6567 19.464" stroke="url(#paint0_linear)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.34299 19.4641L25.6567 12.536" stroke="url(#paint0_linear)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.5359 6.34314L19.464 25.6569" stroke="url(#paint0_linear)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="16" cy="16" r="4" fill="#cebdff"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="4" y1="4" x2="28" y2="28" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#a78bfa"/>
|
||||
<stop offset="1" stop-color="#cebdff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
thirdeye/dashboard/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
thirdeye/dashboard/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
34
thirdeye/dashboard/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||