feat: remove the entire insight feature and its related components.

This commit is contained in:
Haitao Pan 2026-02-02 03:59:45 +08:00
parent b58098aa65
commit 2f34589a54
35 changed files with 0 additions and 3247 deletions

View File

@ -1,225 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { InsightState } from '../../insight/store/urlState'
const quickActions = [
{ id: 'explain', label: 'Explain anomaly', prompt: 'Explain the recent anomaly in my metrics.' },
{ id: 'logs', label: 'Fetch related logs', prompt: 'Show me logs correlated with the current filters.' },
{ id: 'rca', label: 'Root cause analysis', prompt: 'Run an RCA for the checkout service using metrics, logs and traces.' },
{ id: 'alert', label: 'Draft alert', prompt: 'Generate an alert rule for p95 latency over 300ms.' },
{ id: 'report', label: 'Incident report', prompt: 'Create a short incident report for the last hour.' }
]
interface AssistantProps {
state: InsightState
}
export function AIAssistant({ state }: AssistantProps) {
const [isOpen, setIsOpen] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const [isMaximized, setIsMaximized] = useState(false)
const [message, setMessage] = useState('')
const [history, setHistory] = useState<{ question: string; timestamp: number }[]>([])
const [conversation, setConversation] = useState<
{ author: 'user' | 'ai'; text: string; timestamp: number }[]
>([])
const contextSummary = useMemo(
() =>
`Org=${state.org}, Project=${state.project}, Env=${state.env}, Region=${state.region}, Topology=${state.topologyMode}, Service=${state.service || 'all'}, Time=${state.timeRange}`,
[state]
)
function openPanel(prompt?: string) {
setIsOpen(true)
setIsMinimized(false)
if (prompt) {
appendMessage(prompt)
}
}
async function appendMessage(prompt: string) {
const timestamp = Date.now()
const updatedHistory = [{ question: prompt, timestamp }, ...history].slice(0, 10)
setHistory(updatedHistory)
// Optimistic update
setConversation(prev => [
...prev,
{ author: 'user', text: prompt, timestamp },
])
try {
// Include context in the prompt or as a separate field if the API supports it
// For now, we prepend it to the prompt to ensure the bot knows the current view context
const contextAwarePrompt = `Context: [${contextSummary}]\n\nQuestion: ${prompt}`
const response = await fetch('/api/moltbot/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: contextAwarePrompt })
})
if (!response.ok) {
throw new Error(`Error: ${response.statusText}`)
}
const data = await response.json()
const reply = data.reply || data.message || JSON.stringify(data)
setConversation(prev => [
...prev,
{ author: 'ai', text: reply, timestamp: Date.now() }
])
} catch (error: any) {
setConversation(prev => [
...prev,
{ author: 'ai', text: `Sorry, I encountered an error: ${error.message}`, timestamp: Date.now() }
])
}
}
function handleSend() {
if (!message.trim()) return
appendMessage(message.trim())
setMessage('')
}
function toggleMinimize() {
setIsMinimized(prev => !prev)
}
function toggleMaximize() {
setIsMaximized(prev => !prev)
setIsMinimized(false)
}
function closePanel() {
setIsOpen(false)
setIsMinimized(false)
setIsMaximized(false)
}
return (
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20">
<div>
<h3 className="text-sm font-semibold text-slate-100">AI Assistant</h3>
<p className="text-xs text-slate-400">Bring AskAI insights into your observability workflow.</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{quickActions.map(action => (
<button
key={action.id}
onClick={() => openPanel(action.prompt)}
className="rounded-2xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-left text-sm text-slate-200 transition hover:border-emerald-500/60"
>
{action.label}
</button>
))}
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-xs text-slate-300">
<p className="text-[11px] uppercase tracking-wide text-slate-500">Current context</p>
<p className="mt-2 text-[12px] leading-relaxed text-slate-400">{contextSummary}</p>
</div>
<div className="space-y-2 text-xs text-slate-300">
<p className="text-[11px] uppercase tracking-wide text-slate-500">Recent questions</p>
{history.length === 0 ? (
<p className="text-xs text-slate-500">Run a quick action or open the assistant to get started.</p>
) : (
<ul className="space-y-1">
{history.map(item => (
<li key={item.timestamp} className="flex items-center justify-between rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2">
<span className="text-slate-200">{item.question}</span>
<span className="text-slate-500">{new Date(item.timestamp).toLocaleTimeString()}</span>
</li>
))}
</ul>
)}
</div>
<button
onClick={() => openPanel('Help me explore the current observability context.')}
className="w-full rounded-xl bg-emerald-500/80 px-4 py-2 text-sm font-semibold text-emerald-950"
>
Open assistant
</button>
{isOpen && (
<div
className={`fixed right-6 z-40 flex flex-col rounded-3xl border border-emerald-500/30 bg-slate-950/95 shadow-2xl transition-all ${isMaximized ? 'top-6 bottom-6 w-[420px] lg:w-[460px]' : 'bottom-10 w-[360px] lg:w-[400px]'
} ${isMinimized ? 'h-14 overflow-hidden' : 'max-h-[85vh]'}`}
>
<div className="flex items-center justify-between border-b border-emerald-500/20 px-4 py-3">
<div>
<p className="text-sm font-semibold text-slate-100">AI copilot</p>
{!isMinimized && (
<p className="text-[11px] text-slate-400">Context aware responses for the current workspace.</p>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-300">
<button onClick={toggleMinimize} className="rounded-lg border border-transparent px-2 py-1 hover:border-slate-700">
{isMinimized ? 'Restore' : 'Minimize'}
</button>
<button onClick={toggleMaximize} className="rounded-lg border border-transparent px-2 py-1 hover:border-slate-700">
{isMaximized ? 'Default size' : 'Maximize'}
</button>
<button onClick={closePanel} className="rounded-lg border border-transparent px-2 py-1 hover:border-slate-700">
Close
</button>
</div>
</div>
{!isMinimized && (
<div className="flex-1 overflow-y-auto px-4 py-3">
{conversation.length === 0 ? (
<p className="text-xs text-slate-500">
Ask a question to start a conversation. The assistant replies with enriched placeholders until wired to
your backend.
</p>
) : (
<ul className="space-y-3">
{conversation.map(entry => (
<li
key={entry.timestamp}
className={`rounded-2xl px-3 py-2 text-sm ${entry.author === 'user'
? 'bg-emerald-500/10 text-emerald-100'
: 'bg-slate-900/80 text-slate-200'
}`}
>
<p className="text-xs uppercase tracking-wide text-slate-500">
{entry.author === 'user' ? 'You' : 'Assistant'} · {new Date(entry.timestamp).toLocaleTimeString()}
</p>
<p className="mt-1 text-sm leading-relaxed">{entry.text}</p>
</li>
))}
</ul>
)}
</div>
)}
{!isMinimized && (
<div className="border-t border-emerald-500/10 bg-slate-950/80 px-4 py-3">
<textarea
value={message}
onChange={event => setMessage(event.target.value)}
placeholder="Ask anything about this observability view…"
className="h-20 w-full rounded-2xl border border-slate-800 bg-slate-900/80 p-3 text-sm text-slate-200"
/>
<div className="mt-2 flex items-center justify-between text-xs text-slate-400">
<span>Responses include context automatically.</span>
<button
onClick={handleSend}
className="rounded-xl bg-emerald-500/80 px-3 py-1.5 text-xs font-semibold text-emerald-950"
>
Send
</button>
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@ -1,341 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { fetchPromQL } from '../../insight/services/adapters/prometheus'
import { fetchLogs } from '../../insight/services/adapters/logs'
import { fetchTraces } from '../../insight/services/adapters/traces'
import { DataSource, InsightState, QueryInputMode, QueryLanguage } from '../../insight/store/urlState'
import { QueryChips } from '@components/common/QueryChips'
import { QueryHistoryPanel } from '@components/common/QueryHistoryPanel'
interface ExploreBuilderProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
history: Record<QueryLanguage, string[]>
setHistory: (language: QueryLanguage, next: string[]) => void
onResults: (language: QueryLanguage, data: any) => void
panelLanguages?: QueryLanguage[]
}
export const languageMeta: Record<
QueryLanguage,
{ label: string; description: string; dataSource: DataSource; placeholder: string }
> = {
promql: {
label: 'PromQL Explorer',
description: 'Build metrics queries for service SLOs and alerts.',
dataSource: 'metrics',
placeholder: 'sum(rate(http_requests_total{job="api"}[5m]))'
},
logql: {
label: 'LogQL Explorer',
description: 'Stream and filter structured application logs.',
dataSource: 'logs',
placeholder: '{service="checkout"} |= "error"'
},
traceql: {
label: 'TraceQL Explorer',
description: 'Slice and dice distributed tracing data.',
dataSource: 'traces',
placeholder: 'traces{service="checkout"} | duration > 250ms'
}
}
const defaultRecord = <T,>(value: T): Record<QueryLanguage, T> => ({
promql: value,
logql: value,
traceql: value
})
export function ExploreBuilder({
state,
updateState,
history,
setHistory,
onResults,
panelLanguages
}: ExploreBuilderProps) {
const [chipsMap, setChipsMap] = useState<Record<QueryLanguage, string[]>>(defaultRecord<string[]>([]))
const [runningMap, setRunningMap] = useState<Record<QueryLanguage, boolean>>(defaultRecord<boolean>(false))
const [messageMap, setMessageMap] = useState<Record<QueryLanguage, string>>(defaultRecord<string>(''))
const [collapsedMap, setCollapsedMap] = useState<Record<QueryLanguage, boolean>>(defaultRecord<boolean>(false))
const [inputModeMap, setInputModeMap] = useState<Record<QueryLanguage, QueryInputMode>>(defaultRecord<QueryInputMode>('ql'))
const activePanels = useMemo(
() => panelLanguages ?? state.activeLanguages,
[panelLanguages, state.activeLanguages]
)
useEffect(() => {
if (!state.service) return
setChipsMap(prev => {
const serviceChip = `service="${state.service}"`
const next: Record<QueryLanguage, string[]> = { ...prev }
activePanels.forEach(language => {
if (!next[language]) {
next[language] = []
}
if (!next[language].includes(serviceChip)) {
next[language] = [...next[language], serviceChip]
}
})
return next
})
}, [activePanels, state.service])
useEffect(() => {
setInputModeMap(prev => {
const next = { ...prev }
activePanels.forEach(language => {
if (!next[language]) {
next[language] = 'ql'
}
})
return next
})
}, [activePanels])
async function runQuery(language: QueryLanguage) {
const query = state.queries[language] || ''
if (!query) return
const meta = languageMeta[language]
setRunningMap(prev => ({ ...prev, [language]: true }))
setMessageMap(prev => ({ ...prev, [language]: '' }))
try {
let result: any
if (meta.dataSource === 'metrics') {
result = await fetchPromQL(query)
} else if (meta.dataSource === 'logs') {
result = await fetchLogs(query)
} else {
result = await fetchTraces(query)
}
onResults(language, result)
const nextHistory = [query, ...(history[language] || []).filter(item => item !== query)].slice(0, 15)
setHistory(language, nextHistory)
setMessageMap(prev => ({ ...prev, [language]: 'Query executed successfully' }))
} catch (err) {
console.error(err)
setMessageMap(prev => ({ ...prev, [language]: 'Query failed. Please try again later.' }))
} finally {
setRunningMap(prev => ({ ...prev, [language]: false }))
}
}
function removeChip(language: QueryLanguage, label: string) {
setChipsMap(prev => ({
...prev,
[language]: (prev[language] || []).filter(item => item !== label)
}))
}
function toggleMode() {
updateState({ builderMode: state.builderMode === 'visual' ? 'code' : 'visual' })
}
function setInputMode(language: QueryLanguage, mode: QueryInputMode) {
setInputModeMap(prev => ({ ...prev, [language]: mode }))
}
function handleQueryChange(language: QueryLanguage, value: string) {
const dataSource = languageMeta[language].dataSource
setInputMode(language, 'ql')
updateState({
queries: { ...state.queries, [language]: value },
queryLanguage: language,
dataSource,
activeLanguages: Array.from(new Set([...state.activeLanguages, language]))
})
}
function handleHistoryInsert(language: QueryLanguage, query: string) {
handleQueryChange(language, query)
}
function handleCollapse(language: QueryLanguage) {
setCollapsedMap(prev => ({ ...prev, [language]: !prev[language] }))
}
if (activePanels.length === 0) {
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-6 text-sm text-slate-300">
Select a query language from the navigation to start exploring data.
</div>
)
}
const panels = activePanels.map(language => {
const meta = languageMeta[language]
const chips = chipsMap[language] || []
const historyItems = history[language] || []
const isCollapsed = collapsedMap[language]
const inputMode = inputModeMap[language] || 'ql'
return (
<section
key={language}
className="flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20"
>
<header className="panel-drag-handle flex flex-wrap items-start gap-3">
<div>
<h3 className="text-sm font-semibold text-slate-200">{meta.label}</h3>
<p className="text-xs text-slate-400">{meta.description}</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-slate-400">
<div className="flex items-center gap-2">
<span className="hidden sm:inline">Mode:</span>
<div className="flex overflow-hidden rounded-xl border border-slate-800">
<button
type="button"
onClick={() => setInputMode(language, 'ql')}
className={`px-3 py-1 text-xs font-medium transition ${
inputMode === 'ql'
? 'bg-emerald-500/20 text-emerald-200'
: 'bg-slate-900/70 text-slate-400 hover:bg-slate-800'
}`}
>
QL input
</button>
<button
type="button"
onClick={() => setInputMode(language, 'menu')}
className={`px-3 py-1 text-xs font-medium transition ${
inputMode === 'menu'
? 'bg-emerald-500/20 text-emerald-200'
: 'bg-slate-900/70 text-slate-400 hover:bg-slate-800'
}`}
>
Menu select
</button>
</div>
</div>
<button
type="button"
onClick={() => handleCollapse(language)}
className="rounded-xl border border-slate-700 px-3 py-1 hover:bg-slate-800"
>
{isCollapsed ? 'Expand' : 'Collapse'}
</button>
{inputMode === 'ql' && (
<button
type="button"
onClick={toggleMode}
className="rounded-xl border border-slate-700 px-3 py-1 hover:bg-slate-800"
>
{state.builderMode === 'visual' ? 'Switch to code' : 'Switch to visual'}
</button>
)}
</div>
</header>
{!isCollapsed && (
<div className="mt-4 flex flex-1 flex-col gap-4">
{inputMode === 'menu' ? (
<div className="space-y-3 text-xs text-slate-200">
<ContextSummary state={state} />
<div>
<p className="text-[11px] uppercase tracking-wide text-slate-500">Query preview</p>
<pre className="mt-2 max-h-32 overflow-auto rounded-2xl border border-slate-800 bg-slate-950/60 p-3 text-xs text-emerald-200">
{state.queries[language] || meta.placeholder}
</pre>
</div>
</div>
) : state.builderMode === 'visual' ? (
<div className="space-y-3">
<QueryChips labels={chips} onRemove={label => removeChip(language, label)} />
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-xs text-slate-400">
Aggregation
<select className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200">
<option>sum</option>
<option>avg</option>
<option>max</option>
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-slate-400">
Window
<select className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200">
<option>5m</option>
<option>15m</option>
<option>1h</option>
</select>
</label>
</div>
<textarea
value={state.queries[language] || ''}
onChange={event => handleQueryChange(language, event.target.value)}
placeholder={meta.placeholder}
className="h-32 w-full rounded-2xl border border-slate-800 bg-slate-950/60 p-3 text-sm text-slate-200 shadow-inner"
/>
</div>
) : (
<textarea
value={state.queries[language] || ''}
onChange={event => handleQueryChange(language, event.target.value)}
placeholder={meta.placeholder}
className="h-48 w-full rounded-2xl border border-slate-800 bg-slate-950/60 p-3 font-mono text-sm text-slate-200 shadow-inner"
/>
)}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => runQuery(language)}
disabled={runningMap[language]}
className="rounded-xl bg-emerald-500/80 px-4 py-2 text-sm font-semibold text-emerald-950 shadow-lg disabled:opacity-50"
>
{runningMap[language] ? 'Running…' : 'Run query'}
</button>
<button
onClick={() => {
const query = state.queries[language] || meta.placeholder
const unique = [query, ...historyItems.filter(item => item !== query)].slice(0, 15)
setHistory(language, unique)
}}
className="rounded-xl border border-slate-700 px-3 py-2 text-xs text-slate-300 hover:bg-slate-800"
>
Save to history
</button>
{messageMap[language] && <span className="text-xs text-slate-400">{messageMap[language]}</span>}
</div>
<QueryHistoryPanel
history={historyItems}
onInsert={query => handleHistoryInsert(language, query)}
onClear={() => setHistory(language, [])}
/>
</div>
)}
</section>
)
})
if (panels.length === 1) {
return panels[0]
}
return <div className="space-y-4">{panels}</div>
}
function ContextSummary({ state }: { state: InsightState }) {
const context = [
{ label: 'Org', value: state.org },
{ label: 'Environment', value: state.env },
{ label: 'Region', value: state.region },
{ label: 'Project', value: state.project },
{ label: 'Time range', value: state.timeRange }
]
return (
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-3">
<p className="text-[11px] uppercase tracking-wide text-slate-500">Global context</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{context.map(item => (
<span
key={item.label}
className="flex items-center gap-2 rounded-full border border-slate-800 bg-slate-900/70 px-3 py-1 text-xs text-slate-200"
>
<span className="text-slate-500">{item.label}</span>
<span className="font-medium text-slate-100">{item.value}</span>
</span>
))}
</div>
</div>
)
}

View File

@ -1,91 +0,0 @@
'use client'
import { useState } from 'react'
import { InsightState } from '../../insight/store/urlState'
import { TimeRangePicker } from './TimeRangePicker'
interface BreadcrumbBarProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
shareableLink: string
}
const orgs = ['global-org', 'retail-hub', 'fintech-lab']
const projects = ['observability', 'payments', 'edge']
const envs = ['production', 'staging', 'dev']
const regions = ['us-west-2', 'eu-central-1', 'ap-southeast-1']
export function BreadcrumbBar({ state, updateState, shareableLink }: BreadcrumbBarProps) {
const [copied, setCopied] = useState(false)
const canCopy = Boolean(shareableLink)
async function handleCopy() {
if (!canCopy) {
return
}
try {
await navigator.clipboard.writeText(shareableLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Copy failed', err)
}
}
return (
<div className="flex w-full flex-wrap items-center gap-3 text-sm text-slate-200">
<Selector label="Org" value={state.org} options={orgs} onChange={org => updateState({ org })} />
<Separator />
<Selector label="Environment" value={state.env} options={envs} onChange={env => updateState({ env })} />
<Separator />
<Selector label="Region" value={state.region} options={regions} onChange={region => updateState({ region })} />
<Separator />
<Selector label="Project" value={state.project} options={projects} onChange={project => updateState({ project })} />
<Separator />
<TimeRangePicker state={state} updateState={updateState} />
<button
onClick={handleCopy}
disabled={!canCopy}
className={`ml-auto flex items-center gap-2 rounded-xl border border-slate-700 px-3 py-1.5 text-xs font-medium text-slate-200 transition ${
canCopy ? 'hover:bg-slate-800' : 'opacity-60 cursor-not-allowed'
}`}
aria-disabled={!canCopy}
>
{copied ? 'Link copied!' : canCopy ? 'Copy share link' : 'Generating share link...'}
</button>
</div>
)
}
function Selector({
label,
value,
options,
onChange
}: {
label: string
value: string
options: string[]
onChange: (value: string) => void
}) {
return (
<label className="flex min-w-[160px] flex-1 items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/80 px-3 py-1.5 shadow-inner">
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<select
value={value}
onChange={event => onChange(event.target.value)}
className="w-full bg-transparent text-sm font-medium focus:outline-none"
>
{options.map(option => (
<option key={option} value={option} className="bg-slate-900">
{option}
</option>
))}
</select>
</label>
)
}
function Separator() {
return <span className="text-slate-600">/</span>
}

View File

@ -1,216 +0,0 @@
'use client'
import React from 'react'
import { BellRing, Compass, Layers, Sparkles, type LucideIcon, PanelLeftClose, PanelLeftOpen, EyeOff } from 'lucide-react'
import { QueryLanguage, TopologyMode } from '../../insight/store/urlState'
import { SidebarHeader, SidebarContent } from '../../../../components/layout/SidebarRoot'
interface InsightSidebarContentProps {
topologyMode: TopologyMode
activeLanguages: QueryLanguage[]
onSelectSection: (section: string) => void
onTopologyChange: (mode: TopologyMode) => void
onToggleLanguage: (language: QueryLanguage) => void
onToggleCollapse: () => void
onHide: () => void
activeSection: string
collapsed: boolean
}
const sections: { id: string; label: string; icon: LucideIcon }[] = [
{ id: 'topology', label: 'Topology', icon: Layers },
{ id: 'explore', label: 'Explore', icon: Compass },
{ id: 'slo', label: 'SLO & Alerts', icon: BellRing },
{ id: 'ai', label: 'AI Assistant', icon: Sparkles }
]
const topologyOptions: { id: TopologyMode; label: string; hint: string }[] = [
{ id: 'application', label: 'Application', hint: 'Services and dependencies' },
{ id: 'network', label: 'Network', hint: 'Gateways, meshes and edges' },
{ id: 'resource', label: 'Resource', hint: 'Clusters, nodes and workloads' }
]
const languageOptions: { id: QueryLanguage; label: string; description: string }[] = [
{ id: 'promql', label: 'PromQL', description: 'Metrics analytics' },
{ id: 'logql', label: 'LogQL', description: 'Log navigation' },
{ id: 'traceql', label: 'TraceQL', description: 'Trace exploration' }
]
const languageLabels: Record<QueryLanguage, string> = {
promql: 'Prometheus metrics',
logql: 'Log stream',
traceql: 'Distributed traces'
}
export function InsightSidebarContent({
topologyMode,
activeLanguages,
activeSection,
onSelectSection,
onTopologyChange,
onToggleLanguage,
onToggleCollapse,
onHide,
collapsed
}: InsightSidebarContentProps) {
return (
<>
<SidebarHeader className={`flex items-start justify-between mb-7 ${collapsed ? 'flex-col items-center gap-4' : ''}`}>
{!collapsed && (
<div className="space-y-2">
<h1 className="text-lg font-semibold text-slate-100">Observability Workbench</h1>
<p className="text-sm text-slate-400">
Navigate topology, run cross-domain queries and keep SLOs on track.
</p>
</div>
)}
<div className={`flex items-center gap-2 ${collapsed ? '' : 'ml-2'}`}>
<button
type="button"
onClick={onToggleCollapse}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-slate-700 hover:text-slate-100"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</button>
<button
type="button"
onClick={onHide}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-red-500/60 hover:text-red-300"
aria-label="Hide sidebar"
>
<EyeOff className="h-4 w-4" />
</button>
</div>
</SidebarHeader>
<SidebarContent className={`space-y-7 ${collapsed ? 'flex flex-col items-center gap-2' : ''}`}>
<nav className={`space-y-1 ${collapsed ? 'w-full flex flex-col items-center gap-2 space-y-0' : ''}`}>
{sections.map(section => {
const active = activeSection === section.id
const Icon = section.icon
return (
<div key={section.id} className={`group relative ${collapsed ? 'w-full' : ''}`}>
<button
onClick={() => onSelectSection(section.id)}
className={`w-full rounded-xl transition ${collapsed
? 'flex flex-col items-center gap-2 px-2 py-3'
: 'flex items-center gap-3 px-3 py-2 text-left'
} ${active
? 'bg-slate-800 text-slate-100 shadow-inner shadow-slate-800/60'
: 'text-slate-300 hover:bg-slate-800/60'
}`}
title={section.label}
>
<span
className={`flex h-10 w-10 items-center justify-center rounded-xl border ${active ? 'border-slate-700 bg-slate-800 text-slate-100' : 'border-slate-800 bg-slate-900 text-slate-400'
}`}
>
<Icon className="h-5 w-5" />
</span>
{!collapsed && <span className="font-medium">{section.label}</span>}
</button>
{section.id === 'topology' && (
<div
className={`pointer-events-none absolute z-20 hidden w-60 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
}`}
>
<div className="flex flex-col gap-2 text-sm text-slate-200">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Topology mode</p>
<p className="text-xs text-slate-500">Hover to select how the topology map is rendered.</p>
</div>
<div className="flex flex-col gap-2">
{topologyOptions.map(option => {
const activeMode = topologyMode === option.id
return (
<button
key={option.id}
className={`flex flex-col rounded-xl border px-3 py-2 text-left transition ${activeMode
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
}`}
onClick={() => onTopologyChange(option.id)}
type="button"
>
<span className="text-sm font-semibold">{option.label}</span>
<span className="text-xs text-slate-400">{option.hint}</span>
</button>
)
})}
</div>
</div>
</div>
)}
{section.id === 'explore' && (
<div
className={`pointer-events-none absolute z-20 hidden w-64 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
}`}
>
<div className="flex flex-col gap-2 text-sm text-slate-200">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Query domains</p>
<p className="text-xs text-slate-500">Toggle languages to open matching explorers.</p>
</div>
<div className="flex flex-col gap-2">
{languageOptions.map(option => {
const activeLanguage = activeLanguages.includes(option.id)
return (
<button
key={option.id}
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition ${activeLanguage
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
}`}
onClick={() => onToggleLanguage(option.id)}
type="button"
>
<span className="font-medium">{option.label}</span>
<span className="text-xs text-slate-400">{option.description}</span>
</button>
)
})}
</div>
</div>
</div>
)}
</div>
)
})}
</nav>
<div
className={`rounded-2xl border border-slate-800 bg-gradient-to-br from-slate-800/80 to-slate-900 shadow-inner ${collapsed ? 'p-3' : 'p-4'
}`}
>
{!collapsed ? (
<>
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Active explorers</p>
<ul className="space-y-1 text-sm text-slate-300">
{activeLanguages.map(language => (
<li key={language} className="flex items-center justify-between">
<span>{languageLabels[language]}</span>
<span className="text-xs text-slate-500">QL</span>
</li>
))}
{activeLanguages.length === 0 && <li className="text-xs text-slate-500">No languages selected.</li>}
</ul>
</>
) : (
<div className="flex flex-col items-center gap-2">
<p className="text-xs uppercase tracking-wide text-slate-400">Active</p>
<div className="flex flex-col items-center gap-1 text-[10px] text-slate-300">
{activeLanguages.map(language => (
<span key={language}>{languageLabels[language]}</span>
))}
{activeLanguages.length === 0 && <span className="text-slate-500">None</span>}
</div>
</div>
)}
</div>
</SidebarContent>
</>
)
}

View File

@ -1,31 +0,0 @@
'use client'
import React from 'react'
import { QueryLanguage, TopologyMode } from '../../insight/store/urlState'
import { SidebarRoot } from '../../../../components/layout/SidebarRoot'
import { InsightSidebarContent } from './InsightSidebarContent'
interface SidebarProps {
topologyMode: TopologyMode
activeLanguages: QueryLanguage[]
onSelectSection: (section: string) => void
onTopologyChange: (mode: TopologyMode) => void
onToggleLanguage: (language: QueryLanguage) => void
onToggleCollapse: () => void
onHide: () => void
activeSection: string
collapsed: boolean
}
export function Sidebar(props: SidebarProps) {
const { collapsed } = props
return (
<SidebarRoot
className={`border-r border-slate-800 bg-slate-900/70 px-3 py-6 backdrop-blur ${collapsed ? 'w-20' : 'w-full lg:w-72 xl:w-80'
}`}
>
<InsightSidebarContent {...props} />
</SidebarRoot>
)
}

View File

@ -1,33 +0,0 @@
'use client'
import { InsightState } from '../../insight/store/urlState'
import { formatDuration } from '@lib/format'
interface TimeRangePickerProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
}
const ranges = ['15m', '1h', '6h', '24h', '7d']
export function TimeRangePicker({ state, updateState }: TimeRangePickerProps) {
return (
<label className="flex min-w-[160px] flex-1 items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/80 px-3 py-1.5 text-sm text-slate-200 shadow-inner">
<span className="text-xs uppercase tracking-wide text-slate-500">Time range</span>
<select
value={state.timeRange}
onChange={event => updateState({ timeRange: event.target.value })}
className="w-full bg-transparent text-sm font-medium focus:outline-none"
>
{ranges.map(range => (
<option key={range} value={range} className="bg-slate-900">
{formatDuration(range)}
</option>
))}
<option value="custom" className="bg-slate-900">
Custom window
</option>
</select>
</label>
)
}

View File

@ -1,88 +0,0 @@
'use client'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import dynamic from 'next/dynamic'
import type { Layout, ReactGridLayoutProps } from 'react-grid-layout'
interface WorkspacePanel {
id: string
domId?: string
content: ReactNode
minW?: number
minH?: number
}
interface WorkspaceGridProps {
layout: Layout[]
defaultLayout: Layout[]
panels: WorkspacePanel[]
onLayoutChange: (layout: Layout[]) => void
draggableHandle?: string
}
const ReactGridLayout = dynamic(
() =>
import('react-grid-layout').then(mod => {
const baseComponent = mod.default
const widthProvider = (mod as any).WidthProvider ?? (mod.default as any)?.WidthProvider
if (!widthProvider || !baseComponent) {
throw new Error('Unable to load react-grid-layout WidthProvider')
}
const WrappedGrid = (widthProvider as any)(baseComponent as any)
const Adapter: React.ComponentType<ReactGridLayoutProps> = props => <WrappedGrid {...props} />
return Adapter as React.ComponentType<ReactGridLayoutProps>
}),
{ ssr: false }
) as React.ComponentType<ReactGridLayoutProps>
export function WorkspaceGrid({
layout,
defaultLayout,
panels,
onLayoutChange,
draggableHandle
}: WorkspaceGridProps) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const mergedLayout = useMemo(() => {
const defaultsById = Object.fromEntries(defaultLayout.map(item => [item.i, item]))
return layout.map(item => ({
...defaultsById[item.i],
...item
}))
}, [defaultLayout, layout])
if (!mounted) {
return <div className="grid gap-4 md:grid-cols-2"><div className="h-64 animate-pulse rounded-2xl bg-slate-900/60" /><div className="h-64 animate-pulse rounded-2xl bg-slate-900/60" /></div>
}
return (
<div className="insight-grid">
<ReactGridLayout
layout={mergedLayout}
cols={12}
rowHeight={36}
margin={[16, 16]}
containerPadding={[0, 0]}
draggableHandle={draggableHandle}
onLayoutChange={onLayoutChange}
compactType={null}
isBounded
useCSSTransforms
>
{panels.map(panel => (
<div key={panel.id} id={panel.domId}>
<div className="h-full">{panel.content}</div>
</div>
))}
</ReactGridLayout>
</div>
)
}

View File

@ -1,44 +0,0 @@
'use client'
import { InsightState } from '../../insight/store/urlState'
import { BreadcrumbBar } from './BreadcrumbBar'
interface WorkspaceHeaderProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
shareableLink: string
statusMessage?: string | null
showBreadcrumb?: boolean
}
export function WorkspaceHeader({
state,
updateState,
shareableLink,
statusMessage,
showBreadcrumb = true
}: WorkspaceHeaderProps) {
return (
<header className="rounded-2xl border border-slate-800 bg-slate-900/70 px-6 py-5 shadow-lg shadow-slate-950/20">
<div className="space-y-2">
{showBreadcrumb && (
<BreadcrumbBar state={state} updateState={updateState} shareableLink={shareableLink} />
)}
<p className="text-xs text-slate-400">
Drag any panel handle to reorganize the workspace. Saved layouts stay local to your browser.
</p>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400">
<span>
Shareable link:{' '}
<span className="text-slate-200">{shareableLink || 'State syncs in the URL hash for collaboration.'}</span>
</span>
{statusMessage ? (
<span className="text-emerald-300">{statusMessage}</span>
) : (
<span>Changes you save apply only to this device.</span>
)}
</div>
</header>
)
}

View File

@ -1,38 +0,0 @@
import { createOpenObserveClient } from './openobserve'
export interface LogEntry {
timestamp: number
message: string
level: string
service: string
fields?: Record<string, any>
}
const mockLogs: LogEntry[] = Array.from({ length: 25 }).map((_, idx) => ({
timestamp: Date.now() - idx * 15000,
message: `Request handled with status ${idx % 5 === 0 ? 500 : 200}`,
level: idx % 5 === 0 ? 'error' : idx % 3 === 0 ? 'warn' : 'info',
service: idx % 2 === 0 ? 'checkout' : 'payments',
fields: {
traceId: `trace-${idx}`,
spanId: `span-${idx}`
}
}))
export async function fetchLogs(query: string) {
const adapter = createLogsAdapter()
return adapter.queryLogs(query)
}
export function createLogsAdapter(baseUrl?: string, token?: string) {
const client = createOpenObserveClient({ baseUrl, token })
return {
async queryLogs(query: string, params?: Record<string, string>) {
void params
return await client.request<LogEntry[]>(`/logs/query`, {
method: 'POST',
body: JSON.stringify({ query })
})
}
}
}

View File

@ -1,24 +0,0 @@
export interface ClientOptions {
baseUrl?: string
token?: string
}
export function createOpenObserveClient(options: ClientOptions = {}) {
const { baseUrl = 'https://infra.svc.plus/api', token } = options
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers
})
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
return res.json() as Promise<T>
}
return { request }
}

View File

@ -1,49 +0,0 @@
import { createOpenObserveClient } from './openobserve'
interface SeriesPoint {
timestamp: number
value: number
}
export interface PrometheusResponse {
metric: string
points: SeriesPoint[]
}
const mockSeries: PrometheusResponse[] = [
{
metric: 'latency_p95',
points: Array.from({ length: 20 }).map((_, idx) => ({
timestamp: Date.now() - (19 - idx) * 60000,
value: 120 + Math.sin(idx / 2) * 30
}))
},
{
metric: 'error_rate',
points: Array.from({ length: 20 }).map((_, idx) => ({
timestamp: Date.now() - (19 - idx) * 60000,
value: 0.5 + Math.cos(idx / 1.5) * 0.1
}))
}
]
export async function fetchPromQL(query: string) {
const adapter = createPrometheusAdapter()
return adapter.queryRange(query)
}
export function createPrometheusAdapter(baseUrl?: string, token?: string) {
const client = createOpenObserveClient({ baseUrl, token })
return {
async queryRange(query: string, params?: Record<string, string>) {
void params
return await client.request<PrometheusResponse[]>(`/prometheus/api/v1/query`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ query }).toString()
})
}
}
}

View File

@ -1,62 +0,0 @@
import { createOpenObserveClient } from './openobserve'
export interface TraceSpan {
id: string
parentId?: string
name: string
service: string
durationMs: number
startTime: number
}
const mockTrace: TraceSpan[] = [
{
id: 'root',
name: 'GET /checkout',
service: 'gateway',
durationMs: 250,
startTime: Date.now() - 250
},
{
id: 'auth',
parentId: 'root',
name: 'AuthService.verify',
service: 'auth',
durationMs: 45,
startTime: Date.now() - 240
},
{
id: 'payments',
parentId: 'root',
name: 'PaymentService.charge',
service: 'payments',
durationMs: 110,
startTime: Date.now() - 200
},
{
id: 'db',
parentId: 'payments',
name: 'DB.query',
service: 'postgres',
durationMs: 80,
startTime: Date.now() - 180
}
]
export async function fetchTraces(query: string) {
const adapter = createTracesAdapter()
return adapter.queryTraces(query)
}
export function createTracesAdapter(baseUrl?: string, token?: string) {
const client = createOpenObserveClient({ baseUrl, token })
return {
async queryTraces(query: string, params?: Record<string, string>) {
void params
return await client.request<TraceSpan[]>(`/traces/query`, {
method: 'POST',
body: JSON.stringify({ query })
})
}
}
}

View File

@ -1,19 +0,0 @@
import { DataSource } from '../store/urlState'
export function buildCorrelatedQuery(source: DataSource, context: {
service: string
namespace: string
timeRange: string
}) {
const baseLabels = `service="${context.service}"${context.namespace ? `,namespace="${context.namespace}"` : ''}`
switch (source) {
case 'metrics':
return `sum(rate(http_requests_total{${baseLabels}}[5m]))`
case 'logs':
return `{${baseLabels}} | json`
case 'traces':
return `traces{${baseLabels}} | duration > 50ms`
default:
return ''
}
}

View File

@ -1,28 +0,0 @@
export interface AlertConfig {
name: string
description: string
condition: string
severity: 'info' | 'warning' | 'critical'
silenceMinutes?: number
receivers: string[]
}
export interface GitOpsPreview {
branch: string
filePath: string
diff: string
}
export async function createAlertPR(config: AlertConfig): Promise<GitOpsPreview> {
const diff = `--- a/alerts/${config.name}.yaml\n+++ b/alerts/${config.name}.yaml\n` +
`+name: ${config.name}\n` +
`+description: ${config.description}\n` +
`+condition: ${config.condition}\n` +
`+severity: ${config.severity}\n`
await new Promise(resolve => setTimeout(resolve, 400))
return {
branch: `alerts/${config.name}`,
filePath: `alerts/${config.name}.yaml`,
diff
}
}

View File

@ -1,120 +0,0 @@
'use client'
import { useState } from 'react'
import { AlertConfig, createAlertPR } from '../../insight/services/gitops'
interface AlertWizardProps {
template: {
name: string
description: string
condition: string
} | null
onClose: () => void
}
export function AlertWizard({ template, onClose }: AlertWizardProps) {
const [config, setConfig] = useState<AlertConfig>({
name: template?.name ?? 'custom-alert',
description: template?.description ?? '',
condition: template?.condition ?? '',
severity: 'warning',
receivers: [],
silenceMinutes: 0
})
const [preview, setPreview] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
if (!template) return null
async function handleGenerate() {
setIsSubmitting(true)
try {
const result = await createAlertPR(config)
setPreview(result.diff)
} finally {
setIsSubmitting(false)
}
}
function update<K extends keyof AlertConfig>(key: K, value: AlertConfig[K]) {
setConfig(prev => ({ ...prev, [key]: value }))
}
return (
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/80 p-5 shadow-inner">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-slate-100">Alert wizard</h4>
<button onClick={onClose} className="text-xs text-slate-400 hover:text-slate-200">
Close
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2 text-xs text-slate-300">
<label className="flex flex-col gap-1">
Alert name
<input
value={config.name}
onChange={event => update('name', event.target.value)}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
<label className="flex flex-col gap-1">
Severity
<select
value={config.severity}
onChange={event => update('severity', event.target.value as AlertConfig['severity'])}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
</label>
<label className="sm:col-span-2 flex flex-col gap-1">
Condition
<input
value={config.condition}
onChange={event => update('condition', event.target.value)}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
<label className="flex flex-col gap-1">
Silence (minutes)
<input
type="number"
value={config.silenceMinutes ?? 0}
onChange={event => update('silenceMinutes', Number(event.target.value))}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
<label className="flex flex-col gap-1">
Receivers (comma separated)
<input
value={config.receivers.join(', ')}
onChange={event => update('receivers', event.target.value.split(',').map(item => item.trim()).filter(Boolean))}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
<label className="sm:col-span-2 flex flex-col gap-1">
Description
<textarea
value={config.description}
onChange={event => update('description', event.target.value)}
className="h-24 rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 text-sm text-slate-200"
/>
</label>
</div>
<button
onClick={handleGenerate}
disabled={isSubmitting}
className="rounded-xl bg-emerald-500/80 px-4 py-2 text-sm font-semibold text-emerald-950 disabled:opacity-50"
>
{isSubmitting ? 'Generating…' : 'Generate GitOps PR'}
</button>
{preview && (
<pre className="max-h-48 overflow-auto rounded-2xl border border-slate-800 bg-slate-950/60 p-3 text-[12px] text-slate-300">
{preview}
</pre>
)}
</div>
)
}

View File

@ -1,127 +0,0 @@
'use client'
import { useState } from 'react'
import { InsightState, TopologyMode } from '../../insight/store/urlState'
import { AlertWizard } from './AlertWizard'
interface Template {
name: string
title: string
description: string
condition: string
hint: string
}
const templateLibrary: Record<TopologyMode, Template[]> = {
application: [
{
name: 'app-availability',
title: 'Availability ≥ 99.9%',
description: 'Golden signal uptime goal across the selected service mesh.',
condition: 'avg_over_time(up{namespace="default"}[30d]) < 0.999',
hint: 'Map to your core services, adjust window and rollup.'
},
{
name: 'app-latency',
title: 'P95 latency < 300ms',
description: 'Track tail latency for customer-facing endpoints.',
condition: 'histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m]))) > 0.3',
hint: 'Swap service label and percentile to match expectations.'
},
{
name: 'app-error',
title: 'Error rate < 1%',
description: 'Alert when errors exceed the burn rate for this application.',
condition:
'sum(rate(http_requests_total{code=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.01',
hint: 'Tune thresholds per service-level objective.'
}
],
network: [
{
name: 'net-availability',
title: 'L4 success rate ≥ 99.95%',
description: 'Monitor gateway and load-balancer success ratio.',
condition: 'avg_over_time(connection_success_total[30d]) < 0.9995',
hint: 'Hook into your L4 metrics exporter or eBPF pipeline.'
},
{
name: 'net-latency',
title: 'Network latency < 80ms',
description: 'Watch round-trip time between edge and cluster ingress.',
condition: 'histogram_quantile(0.95, sum(rate(network_rtt_bucket[5m]))) > 0.08',
hint: 'Adjust quantiles per region or provider SLA.'
},
{
name: 'net-packets',
title: 'Packet drop < 0.1%',
description: 'Alert when drops spike on critical gateways.',
condition: 'sum(rate(packet_drop_total[5m])) / sum(rate(packet_total[5m])) > 0.001',
hint: 'Swap metrics for your own networking stack.'
}
],
resource: [
{
name: 'res-cpu',
title: 'CPU saturation < 75%',
description: 'Keep node CPU usage below target across the fleet.',
condition: 'avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) < 0.25',
hint: 'Track per node pool or namespace as needed.'
},
{
name: 'res-memory',
title: 'Memory headroom > 30%',
description: 'Alert when memory usage trends toward exhaustion.',
condition: 'avg(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) < 0.3',
hint: 'Swap metrics for container-level if required.'
},
{
name: 'res-storage',
title: 'Storage IO latency < 20ms',
description: 'Keep disk IO latency within SLO budgets.',
condition: 'avg(rate(node_disk_io_time_seconds_total[5m])) > 0.02',
hint: 'Extend with per-volume thresholds or burst alerts.'
}
]
}
interface SLOPanelProps {
state: InsightState
}
export function SLOPanel({ state }: SLOPanelProps) {
const templates = templateLibrary[state.topologyMode]
const [selected, setSelected] = useState<Template | null>(null)
return (
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20">
<div>
<h3 className="text-sm font-semibold text-slate-100">SLO templates</h3>
<p className="text-xs text-slate-400">
Golden signals curated for {modeTitles[state.topologyMode]}. ({state.env} / {state.region})
</p>
<p className="text-[11px] text-slate-500">Extend or replace each template with your own objectives.</p>
</div>
<div className="space-y-3">
{templates.map(template => (
<button
key={template.name}
onClick={() => setSelected(template)}
className="w-full rounded-2xl border border-slate-800 bg-slate-950/60 px-4 py-3 text-left transition hover:border-emerald-500/60"
>
<p className="text-sm font-medium text-slate-200">{template.title}</p>
<p className="text-xs text-slate-400">{template.description}</p>
<p className="text-[11px] text-slate-500">{template.hint}</p>
</button>
))}
</div>
{selected && <AlertWizard template={selected} onClose={() => setSelected(null)} />}
</div>
)
}
const modeTitles: Record<TopologyMode, string> = {
application: 'application monitoring',
network: 'L4 network health',
resource: 'infrastructure resources'
}

View File

@ -1,96 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { InsightState } from '../../insight/store/urlState'
import { canAccessSnippet } from '@lib/rbac'
interface Snippet {
name: string
domain: 'metrics' | 'logs' | 'traces'
query: string
tags: string[]
rbac?: {
roles?: string[]
environments?: string[]
}
}
interface SnippetLibraryProps {
state: InsightState
onInsert: (query: string, domain: Snippet['domain']) => void
}
const snippets: Snippet[] = [
{
name: 'Checkout error budget',
domain: 'metrics',
query: 'sum(rate(http_requests_total{service="checkout",code=~"5.."}[5m]))',
tags: ['latency', 'error'],
rbac: { environments: ['production', 'staging'] }
},
{
name: 'Payments slow requests',
domain: 'logs',
query: '{service="payments"} |= "duration" |= "slow"',
tags: ['payments', 'latency']
},
{
name: 'Trace checkout flow',
domain: 'traces',
query: 'traces{service="checkout"} | duration > 100ms',
tags: ['trace', 'checkout'],
rbac: { roles: ['sre', 'platform'] }
}
]
export function SnippetLibrary({ state, onInsert }: SnippetLibraryProps) {
const [filter, setFilter] = useState('')
const accessibleSnippets = useMemo(() => {
const normalized = filter.trim().toLowerCase()
return snippets.filter(snippet => {
if (!canAccessSnippet(snippet.rbac, { role: 'sre', env: state.env })) return false
if (!normalized) return true
return (
snippet.name.toLowerCase().includes(normalized) ||
snippet.query.toLowerCase().includes(normalized) ||
snippet.tags.some(tag => tag.toLowerCase().includes(normalized))
)
})
}, [filter, state.env])
return (
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-100">Team snippets</h3>
<input
value={filter}
onChange={event => setFilter(event.target.value)}
placeholder="Search tags"
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-1.5 text-xs text-slate-200"
/>
</div>
<div className="space-y-3">
{accessibleSnippets.length === 0 ? (
<p className="text-xs text-slate-500">No snippets found for the current filters.</p>
) : (
accessibleSnippets.map(snippet => (
<div key={snippet.name} className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
<div className="flex items-center justify-between text-sm text-slate-200">
<span className="font-medium">{snippet.name}</span>
<span className="text-xs uppercase tracking-wide text-slate-500">{snippet.domain}</span>
</div>
<p className="mt-1 text-xs text-slate-400">Tags: {snippet.tags.join(', ')}</p>
<pre className="mt-2 overflow-x-auto rounded-xl bg-slate-900/70 p-3 text-[12px] text-slate-300">{snippet.query}</pre>
<button
onClick={() => onInsert(snippet.query, snippet.domain)}
className="mt-3 rounded-xl border border-slate-700 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800"
>
Insert into builder
</button>
</div>
))
)}
</div>
</div>
)
}

View File

@ -1,209 +0,0 @@
export type TopologyMode = 'application' | 'network' | 'resource'
export type DataSource = 'metrics' | 'logs' | 'traces'
export type BuilderMode = 'visual' | 'code'
export type QueryLanguage = 'promql' | 'logql' | 'traceql'
export type QueryInputMode = 'ql' | 'menu'
export interface InsightState {
org: string
project: string
env: string
region: string
topologyMode: TopologyMode
namespace: string
service: string
dataSource: DataSource
queryLanguage: QueryLanguage
queries: Record<QueryLanguage, string>
activeLanguages: QueryLanguage[]
builderMode: BuilderMode
timeRange: string
}
export const DEFAULT_INSIGHT_STATE: InsightState = {
org: 'global-org',
project: 'observability',
env: 'production',
region: 'us-west-2',
topologyMode: 'application',
namespace: 'default',
service: '',
dataSource: 'metrics',
queryLanguage: 'promql',
queries: {
promql: 'sum(rate(http_requests_total{job="api"}[5m]))',
logql: '{service="checkout"} |= "error"',
traceql: 'traces{service="checkout"} | duration > 250ms'
},
activeLanguages: ['promql'],
builderMode: 'visual',
timeRange: '1h'
}
const STATE_KEY_MAP: Record<keyof InsightState, string> = {
org: 'org',
project: 'proj',
env: 'env',
region: 'reg',
topologyMode: 'mode',
namespace: 'ns',
service: 'svc',
dataSource: 'ds',
queryLanguage: 'ql',
queries: 'qs',
activeLanguages: 'qls',
builderMode: 'bm',
timeRange: 'tr'
}
const REVERSE_STATE_KEY_MAP = Object.fromEntries(
Object.entries(STATE_KEY_MAP).map(([key, value]) => [value, key])
) as Record<string, keyof InsightState>
export function serializeInsightState(state: InsightState): string {
const params = new URLSearchParams()
;(Object.keys(STATE_KEY_MAP) as (keyof InsightState)[]).forEach(key => {
const value = state[key]
if (value === undefined || value === null) return
switch (key) {
case 'queries': {
const queries = state.queries
if (!queries) return
const customQueries: Partial<Record<QueryLanguage, string>> = {}
;(Object.keys(queries) as QueryLanguage[]).forEach(language => {
if (queries[language] !== DEFAULT_INSIGHT_STATE.queries[language]) {
customQueries[language] = queries[language]
}
})
if (Object.keys(customQueries).length === 0) return
params.set(
STATE_KEY_MAP[key],
encodeURIComponent(JSON.stringify(customQueries))
)
break
}
case 'activeLanguages': {
const activeLanguages = Array.isArray(value)
? value
: [value as QueryLanguage]
const defaultActive = DEFAULT_INSIGHT_STATE.activeLanguages
if (
activeLanguages.length === defaultActive.length &&
activeLanguages.every((language, index) => language === defaultActive[index])
) {
return
}
params.set(STATE_KEY_MAP[key], activeLanguages.join(','))
break
}
default:
if (value === DEFAULT_INSIGHT_STATE[key]) return
params.set(STATE_KEY_MAP[key], String(value))
}
})
return params.toString()
}
export function deserializeInsightState(hash: string): InsightState {
if (!hash) return DEFAULT_INSIGHT_STATE
const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash
const params = new URLSearchParams(cleanHash)
const next: InsightState = { ...DEFAULT_INSIGHT_STATE }
params.forEach((value, key) => {
const stateKey = REVERSE_STATE_KEY_MAP[key]
if (!stateKey) return
switch (stateKey) {
case 'topologyMode':
next.topologyMode = value as TopologyMode
break
case 'dataSource':
next.dataSource = value as DataSource
break
case 'queryLanguage':
next.queryLanguage = value as QueryLanguage
break
case 'builderMode':
next.builderMode = value as BuilderMode
break
case 'org':
next.org = value
break
case 'project':
next.project = value
break
case 'env':
next.env = value
break
case 'region':
next.region = value
break
case 'namespace':
next.namespace = value
break
case 'service':
next.service = value
break
case 'queries':
try {
const decoded = decodeURIComponent(value)
const parsed = JSON.parse(decoded)
next.queries = {
promql: parsed.promql || DEFAULT_INSIGHT_STATE.queries.promql,
logql: parsed.logql || DEFAULT_INSIGHT_STATE.queries.logql,
traceql: parsed.traceql || DEFAULT_INSIGHT_STATE.queries.traceql
}
} catch (error) {
console.error('Failed to parse queries from URL state', error)
next.queries = { ...DEFAULT_INSIGHT_STATE.queries }
}
break
case 'activeLanguages':
next.activeLanguages = value
.split(',')
.map(item => item.trim())
.filter(Boolean) as QueryLanguage[]
if (next.activeLanguages.length === 0) {
next.activeLanguages = [...DEFAULT_INSIGHT_STATE.activeLanguages]
}
break
case 'timeRange':
next.timeRange = value
break
default:
break
}
})
if (!['application', 'network', 'resource'].includes(next.topologyMode)) {
next.topologyMode = DEFAULT_INSIGHT_STATE.topologyMode
}
if (!['metrics', 'logs', 'traces'].includes(next.dataSource)) {
next.dataSource = DEFAULT_INSIGHT_STATE.dataSource
}
if (!['promql', 'logql', 'traceql'].includes(next.queryLanguage)) {
next.queryLanguage = DEFAULT_INSIGHT_STATE.queryLanguage
}
if (!['visual', 'code'].includes(next.builderMode)) {
next.builderMode = DEFAULT_INSIGHT_STATE.builderMode
}
next.activeLanguages = next.activeLanguages.filter(language =>
['promql', 'logql', 'traceql'].includes(language)
) as QueryLanguage[]
if (next.activeLanguages.length === 0) {
next.activeLanguages = [...DEFAULT_INSIGHT_STATE.activeLanguages]
}
if (!next.queries) {
next.queries = { ...DEFAULT_INSIGHT_STATE.queries }
} else {
next.queries = {
promql: next.queries.promql || DEFAULT_INSIGHT_STATE.queries.promql,
logql: next.queries.logql || DEFAULT_INSIGHT_STATE.queries.logql,
traceql: next.queries.traceql || DEFAULT_INSIGHT_STATE.queries.traceql
}
}
return next
}

View File

@ -1,154 +0,0 @@
'use client'
import { create } from 'zustand'
import {
DEFAULT_INSIGHT_STATE,
InsightState,
serializeInsightState,
deserializeInsightState
} from './urlState'
function getSegments(pathname: string): string[] {
return pathname
.split('/')
.map(segment => segment.trim())
.filter(Boolean)
}
function getBasePath(pathname: string): string {
const segments = getSegments(pathname)
const insightIndex = segments.indexOf('insight')
if (insightIndex === -1) {
return pathname || '/insight'
}
const relevant = segments.slice(0, insightIndex + 1)
return `/${relevant.join('/')}`
}
function getShareIdFromSearch(search: string): string {
if (!search) return ''
const params = new URLSearchParams(search)
return params.get('share') ?? ''
}
function encodeStateId(value: string): string {
if (!value) return ''
const base64 =
typeof window !== 'undefined' && typeof window.btoa === 'function'
? window.btoa(value)
: Buffer.from(value, 'utf-8').toString('base64')
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function decodeStateId(value: string): string | null {
if (!value) return null
try {
const padded = value.replace(/-/g, '+').replace(/_/g, '/')
const padLength = (4 - (padded.length % 4)) % 4
const base64 = padded + '='.repeat(padLength)
return typeof window !== 'undefined' && typeof window.atob === 'function'
? window.atob(base64)
: Buffer.from(base64, 'base64').toString('utf-8')
} catch (error) {
console.error('Failed to decode insight share identifier', error)
return null
}
}
function resolveBaseUrl() {
if (typeof window !== 'undefined') {
const { origin, pathname } = window.location
const basePath = getBasePath(pathname)
return `${origin}${basePath}`
}
const configured = process.env.NEXT_PUBLIC_SITE_URL
if (configured) {
const normalized = configured.endsWith('/') ? configured.slice(0, -1) : configured
return normalized.endsWith('/insight') ? normalized : `${normalized}/insight`
}
return ''
}
type InsightStore = {
state: InsightState
shareableLink: string
setInsight: (next: InsightState) => void
updateInsight: (partial: Partial<InsightState>) => void
hydrateFromURL: () => InsightState
syncToURL: () => void
}
export const useInsightStore = create<InsightStore>((set, get) => ({
state: DEFAULT_INSIGHT_STATE,
shareableLink: '',
setInsight: (next) => set({ state: next }),
updateInsight: (partial) =>
set((current) => ({
state: {
...current.state,
...partial,
},
})),
hydrateFromURL: () => {
if (typeof window === 'undefined') {
return get().state
}
const shareId = getShareIdFromSearch(window.location.search)
if (shareId) {
const decoded = decodeStateId(shareId)
if (decoded) {
const hydrated = deserializeInsightState(decoded)
set({ state: hydrated })
return hydrated
}
}
const hydrated = deserializeInsightState(window.location.hash)
set({ state: hydrated })
return hydrated
},
syncToURL: () => {
if (typeof window === 'undefined') {
set({ shareableLink: resolveBaseUrl() })
return
}
const serializedState = serializeInsightState(get().state)
const encoded = encodeStateId(serializedState)
const currentUrl = new URL(window.location.href)
const basePath = getBasePath(currentUrl.pathname)
if (currentUrl.pathname !== basePath) {
currentUrl.pathname = basePath
}
if (encoded) {
currentUrl.searchParams.set('share', encoded)
} else {
currentUrl.searchParams.delete('share')
}
if (!currentUrl.searchParams.toString()) {
currentUrl.search = ''
}
if (currentUrl.hash) {
currentUrl.hash = ''
}
const nextUrl = currentUrl.toString()
if (nextUrl !== window.location.href) {
window.history.replaceState({}, '', nextUrl)
}
const baseUrl = resolveBaseUrl()
set({ shareableLink: encoded ? `${baseUrl}?share=${encoded}` : baseUrl })
},
}))
if (typeof window !== 'undefined') {
useInsightStore.getState().hydrateFromURL()
useInsightStore.subscribe(
(storeState, prevState) => {
if (!prevState || storeState.state === prevState.state) return
useInsightStore.getState().syncToURL()
},
)
useInsightStore.getState().syncToURL()
}

View File

@ -1,92 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { InsightState } from '../../insight/store/urlState'
import { TopologyCanvas } from './TopologyCanvas'
interface NetworkTopologyPanelProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
}
export function NetworkTopologyPanel({ state, updateState }: NetworkTopologyPanelProps) {
const [advancedVisible, setAdvancedVisible] = useState(false)
const subtitle = useMemo(
() => `${state.env} · ${state.region} · ${state.org}/${state.project}`,
[state.env, state.org, state.project, state.region]
)
return (
<section
id="topology"
className="flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20"
>
<header className="panel-drag-handle flex flex-wrap items-start gap-3">
<div className="space-y-1">
<h2 className="text-base font-semibold text-slate-200">Network topology</h2>
<p className="text-xs text-slate-400">{subtitle}</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-slate-300">
<button
type="button"
onClick={() => setAdvancedVisible(prev => !prev)}
className="rounded-xl border border-slate-800 px-3 py-1 transition hover:border-slate-700 hover:text-slate-100"
>
{advancedVisible ? 'Hide advanced filters' : 'Show advanced filters'}
</button>
<button
type="button"
onClick={() => updateState({ topologyMode: 'network' })}
className="rounded-xl border border-emerald-600/50 bg-emerald-500/10 px-3 py-1 font-medium text-emerald-200 shadow-inner shadow-emerald-500/20 transition hover:border-emerald-400/70"
>
Focus network
</button>
</div>
</header>
{advancedVisible && (
<div className="mt-4 flex flex-wrap gap-3 text-xs text-slate-300">
<TextField
label="Namespace"
value={state.namespace}
placeholder="default"
onChange={value => updateState({ namespace: value })}
/>
<TextField
label="Service"
value={state.service}
placeholder="all services"
onChange={value => updateState({ service: value })}
/>
</div>
)}
<div className="mt-5 flex-1 overflow-hidden">
<TopologyCanvas state={state} updateState={updateState} />
</div>
</section>
)
}
interface TextFieldProps {
label: string
value: string
placeholder: string
onChange: (value: string) => void
}
function TextField({ label, value, placeholder, onChange }: TextFieldProps) {
return (
<label className="flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2">
<span className="text-[11px] uppercase tracking-wide text-slate-500">{label}</span>
<input
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
className="bg-transparent text-sm text-slate-200 focus:outline-none"
/>
</label>
)
}

View File

@ -1,189 +0,0 @@
'use client'
import { MouseEvent, useMemo, useState } from 'react'
import { buildCorrelatedQuery } from '../../insight/services/correlator'
import { InsightState } from '../../insight/store/urlState'
import { TopologyEdge, TopologyNode } from './types'
interface TopologyCanvasProps {
state: InsightState
updateState: (partial: Partial<InsightState>) => void
}
interface ContextMenuState {
visible: boolean
x: number
y: number
node?: TopologyNode
}
export function TopologyCanvas({ state, updateState }: TopologyCanvasProps) {
const nodes = useMemo(() => createMockNodes(state.topologyMode), [state.topologyMode])
const edges = useMemo(() => createMockEdges(nodes), [nodes])
const [contextMenu, setContextMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0 })
function handleNodeClick(node: TopologyNode) {
if (node.service) {
updateState({ service: node.service })
}
}
function handleContextMenu(event: MouseEvent, node: TopologyNode) {
event.preventDefault()
setContextMenu({
visible: true,
x: event.clientX,
y: event.clientY,
node
})
}
function handleInspect(target: 'metrics' | 'logs' | 'traces') {
if (!contextMenu.node) return
const query = buildCorrelatedQuery(target, {
service: contextMenu.node.service ?? contextMenu.node.label,
namespace: state.namespace,
timeRange: state.timeRange
})
const language = target === 'metrics' ? 'promql' : target === 'logs' ? 'logql' : 'traceql'
updateState({
dataSource: target,
queryLanguage: language,
queries: { ...state.queries, [language]: query },
activeLanguages: Array.from(new Set([...state.activeLanguages, language])),
service: contextMenu.node.service ?? contextMenu.node.label
})
setContextMenu({ visible: false, x: 0, y: 0 })
}
return (
<div className="relative rounded-2xl border border-slate-800 bg-slate-950/60 p-4 shadow-inner">
<svg viewBox="0 0 600 320" className="h-80 w-full">
{edges.map(edge => {
const from = nodes.find(n => n.id === edge.from)
const to = nodes.find(n => n.id === edge.to)
if (!from || !to) return null
return (
<g key={edge.id}>
<line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke="#334155"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
<text x={(from.x + to.x) / 2} y={(from.y + to.y) / 2 - 8} className="fill-slate-400 text-[10px]">
{edge.latencyMs.toFixed(0)}ms
</text>
</g>
)
})}
<defs>
<marker
id="arrowhead"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#334155" />
</marker>
</defs>
{nodes.map(node => (
<g
key={node.id}
transform={`translate(${node.x - 60}, ${node.y - 24})`}
className="cursor-pointer"
onClick={() => handleNodeClick(node)}
onContextMenu={event => handleContextMenu(event, node)}
>
<rect
width={120}
height={48}
rx={18}
className={`stroke-2 ${statusStyles[node.status]} ${
state.service === node.service ? 'stroke-emerald-400' : 'stroke-transparent'
}`}
/>
<rect
width={120}
height={48}
rx={18}
className={`fill-slate-900/90 backdrop-blur ${state.service === node.service ? 'ring-2 ring-emerald-400/60' : ''}`}
/>
<text x={16} y={22} className="fill-slate-200 text-sm font-medium">
{node.label}
</text>
<text x={16} y={36} className="fill-slate-500 text-[11px] uppercase tracking-wide">
{node.type}
</text>
</g>
))}
</svg>
{contextMenu.visible && contextMenu.node && (
<div
className="absolute z-20 rounded-xl border border-slate-700 bg-slate-900/95 px-3 py-2 text-sm text-slate-200 shadow-xl"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<p className="mb-2 text-xs uppercase tracking-wide text-slate-500">Inspect {contextMenu.node.label}</p>
<div className="flex flex-col">
<button onClick={() => handleInspect('metrics')} className="rounded-lg px-2 py-1 text-left hover:bg-slate-800">
View Metrics
</button>
<button onClick={() => handleInspect('logs')} className="rounded-lg px-2 py-1 text-left hover:bg-slate-800">
View Logs
</button>
<button onClick={() => handleInspect('traces')} className="rounded-lg px-2 py-1 text-left hover:bg-slate-800">
View Traces
</button>
</div>
</div>
)}
{contextMenu.visible && (
<div className="absolute inset-0" onClick={() => setContextMenu({ visible: false, x: 0, y: 0 })} />
)}
</div>
)
}
function createMockNodes(mode: InsightState['topologyMode']): TopologyNode[] {
switch (mode) {
case 'network':
return [
{ id: '1', label: 'Edge Router', type: 'network', status: 'healthy', x: 100, y: 140 },
{ id: '2', label: 'Service Mesh', type: 'network', status: 'warning', x: 260, y: 140 },
{ id: '3', label: 'Kubernetes', type: 'network', status: 'healthy', x: 440, y: 140 }
]
case 'resource':
return [
{ id: 'node', label: 'Node pool', type: 'database', status: 'healthy', x: 120, y: 160 },
{ id: 'pod', label: 'Checkout pod', type: 'service', status: 'warning', x: 300, y: 120, service: 'checkout' },
{ id: 'db', label: 'Postgres', type: 'database', status: 'critical', x: 480, y: 180, service: 'postgres' }
]
default:
return [
{ id: 'gw', label: 'API Gateway', type: 'gateway', status: 'healthy', x: 120, y: 120, service: 'gateway' },
{ id: 'checkout', label: 'Checkout', type: 'service', status: 'warning', x: 300, y: 160, service: 'checkout' },
{ id: 'payments', label: 'Payments', type: 'service', status: 'healthy', x: 480, y: 120, service: 'payments' }
]
}
}
function createMockEdges(nodes: TopologyNode[]): TopologyEdge[] {
if (nodes.length < 2) return []
const edges: TopologyEdge[] = []
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({ id: `${nodes[i].id}-${nodes[i + 1].id}`, from: nodes[i].id, to: nodes[i + 1].id, latencyMs: 20 + i * 15 })
}
return edges
}
const statusStyles: Record<TopologyNode['status'], string> = {
healthy: 'stroke-emerald-500/60',
warning: 'stroke-amber-400/60',
critical: 'stroke-red-500/60'
}

View File

@ -1,16 +0,0 @@
export interface TopologyNode {
id: string
label: string
type: 'service' | 'gateway' | 'database' | 'network'
status: 'healthy' | 'warning' | 'critical'
x: number
y: number
service?: string
}
export interface TopologyEdge {
id: string
from: string
to: string
latencyMs: number
}

View File

@ -1,49 +0,0 @@
'use client'
import { LogEntry } from '../../insight/services/adapters/logs'
import { getLogLevelColor } from '@lib/format'
interface LogsTableProps {
logs: LogEntry[]
}
export function LogsTable({ logs }: LogsTableProps) {
if (!logs.length) {
return <p className="text-sm text-slate-400">Run a LogQL query to inspect log lines in a table.</p>
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm text-slate-200">
<thead className="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th scope="col" className="px-4 py-2 font-medium">
Time
</th>
<th scope="col" className="px-4 py-2 font-medium">
Level
</th>
<th scope="col" className="px-4 py-2 font-medium">
Service
</th>
<th scope="col" className="px-4 py-2 font-medium">
Message
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{logs.map(log => (
<tr key={`${log.timestamp}-${log.message}`} className="bg-slate-950/50">
<td className="px-4 py-2 text-xs text-slate-400">
{new Date(log.timestamp).toLocaleTimeString()}
</td>
<td className={`px-4 py-2 text-xs font-semibold ${getLogLevelColor(log.level)}`}>{log.level.toUpperCase()}</td>
<td className="px-4 py-2 text-xs text-slate-300">{log.service}</td>
<td className="px-4 py-2 font-mono text-[13px] text-slate-200">{log.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -1,59 +0,0 @@
'use client'
import { LogEntry } from '../../insight/services/adapters/logs'
interface LogsTopStatsProps {
logs: LogEntry[]
}
export function LogsTopStats({ logs }: LogsTopStatsProps) {
if (!logs.length) {
return <p className="text-sm text-slate-400">Run a LogQL query to review top-level log insights.</p>
}
const total = logs.length
const errorCount = logs.filter(log => log.level.toLowerCase() === 'error').length
const errorRate = total ? (errorCount / total) * 100 : 0
const serviceCounts = logs.reduce<Record<string, number>>((acc, log) => {
acc[log.service] = (acc[log.service] ?? 0) + 1
return acc
}, {})
const [topService, topServiceCount] = Object.entries(serviceCounts).sort((a, b) => b[1] - a[1])[0]
const levelCounts = logs.reduce<Record<string, number>>((acc, log) => {
const level = log.level.toUpperCase()
acc[level] = (acc[level] ?? 0) + 1
return acc
}, {})
return (
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Log volume</p>
<p className="mt-1 text-2xl font-semibold text-slate-100">{total}</p>
<p className="text-xs text-slate-500">Entries returned</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Error rate</p>
<p className="mt-1 text-2xl font-semibold text-rose-300">{errorRate.toFixed(1)}%</p>
<p className="text-xs text-slate-500">{errorCount} errors</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Top service</p>
<p className="mt-1 text-xl font-semibold text-slate-100">{topService ?? 'unknown'}</p>
<p className="text-xs text-slate-500">{topServiceCount ?? 0} entries</p>
</div>
<div className="sm:col-span-3 rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Level distribution</p>
<div className="mt-2 flex flex-wrap gap-3 text-xs">
{Object.entries(levelCounts).map(([level, count]) => (
<span key={level} className="rounded-full border border-slate-800 px-3 py-1 text-slate-300">
{level}: {count}
</span>
))}
</div>
</div>
</div>
)
}

View File

@ -1,36 +0,0 @@
'use client'
import { LogEntry } from '../../insight/services/adapters/logs'
import { getLogLevelColor } from '@lib/format'
interface LogsViewerProps {
logs: LogEntry[]
}
export function LogsViewer({ logs }: LogsViewerProps) {
if (!logs.length) {
return <p className="text-sm text-slate-400">Run a LogQL query to inspect log events.</p>
}
return (
<div className="space-y-2">
{logs.map(log => (
<div
key={`${log.timestamp}-${log.message}`}
className="rounded-xl border border-slate-800 bg-slate-950/60 p-3 text-sm text-slate-200 shadow-inner"
>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className={getLogLevelColor(log.level)}>{log.level.toUpperCase()}</span>
</div>
<p className="mt-2 font-mono text-[13px] text-slate-300">{log.message}</p>
{log.fields && (
<pre className="mt-2 overflow-x-auto rounded-lg bg-slate-900/80 p-2 text-[11px] text-slate-400">
{JSON.stringify(log.fields, null, 2)}
</pre>
)}
</div>
))}
</div>
)
}

View File

@ -1,48 +0,0 @@
'use client'
import { PrometheusResponse } from '../../insight/services/adapters/prometheus'
import { formatNumber } from '@lib/format'
interface MetricsChartProps {
series: PrometheusResponse[]
}
export function MetricsChart({ series }: MetricsChartProps) {
if (!series.length) {
return <p className="text-sm text-slate-400">Run a query to render time series data.</p>
}
const width = 520
const height = 200
const flat = series.flatMap(s => s.points)
const min = Math.min(...flat.map(p => p.value))
const max = Math.max(...flat.map(p => p.value))
const range = max - min || 1
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full">
{series.map((s, idx) => {
const path = s.points
.map((point, i) => {
const x = (i / Math.max(1, s.points.length - 1)) * (width - 40) + 20
const y = height - 20 - ((point.value - min) / range) * (height - 40)
return `${i === 0 ? 'M' : 'L'}${x},${y}`
})
.join(' ')
const color = palette[idx % palette.length]
return (
<g key={s.metric}>
<path d={path} fill="none" stroke={color} strokeWidth={2} />
<text x={24} y={20 + idx * 16} className="fill-slate-300 text-[11px]">
{s.metric}: {formatNumber(s.points[s.points.length - 1]?.value ?? 0)}
</text>
</g>
)
})}
<line x1={20} y1={height - 20} x2={width - 20} y2={height - 20} stroke="#1e293b" />
<line x1={20} y1={20} x2={20} y2={height - 20} stroke="#1e293b" />
</svg>
)
}
const palette = ['#34d399', '#60a5fa', '#fbbf24']

View File

@ -1,66 +0,0 @@
'use client'
import { PrometheusResponse } from '../../insight/services/adapters/prometheus'
import { formatNumber } from '@lib/format'
interface MetricsTableProps {
series: PrometheusResponse[]
}
export function MetricsTable({ series }: MetricsTableProps) {
if (!series.length) {
return <p className="text-sm text-slate-400">Run a query to inspect series in a tabular view.</p>
}
const rows = series.map(item => {
const values = item.points.map(point => point.value)
const latest = values.at(-1) ?? 0
const min = Math.min(...values)
const max = Math.max(...values)
const avg = values.reduce((acc, value) => acc + value, 0) / (values.length || 1)
return {
metric: item.metric,
latest,
min,
max,
avg
}
})
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm text-slate-200">
<thead className="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th scope="col" className="px-4 py-2 font-medium">
Metric
</th>
<th scope="col" className="px-4 py-2 font-medium">
Latest
</th>
<th scope="col" className="px-4 py-2 font-medium">
Average
</th>
<th scope="col" className="px-4 py-2 font-medium">
Min
</th>
<th scope="col" className="px-4 py-2 font-medium">
Max
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{rows.map(row => (
<tr key={row.metric} className="bg-slate-950/50">
<td className="px-4 py-2 font-medium text-slate-100">{row.metric}</td>
<td className="px-4 py-2">{formatNumber(row.latest)}</td>
<td className="px-4 py-2">{formatNumber(row.avg)}</td>
<td className="px-4 py-2">{formatNumber(row.min)}</td>
<td className="px-4 py-2">{formatNumber(row.max)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -1,45 +0,0 @@
'use client'
import { PrometheusResponse } from '../../insight/services/adapters/prometheus'
import { formatNumber } from '@lib/format'
interface MetricsTopStatsProps {
series: PrometheusResponse[]
}
export function MetricsTopStats({ series }: MetricsTopStatsProps) {
if (!series.length) {
return <p className="text-sm text-slate-400">Run a query to surface top metrics and aggregates.</p>
}
const ranked = series
.map(item => {
const values = item.points.map(point => point.value)
const latest = values.at(-1) ?? 0
const peak = Math.max(...values)
return { metric: item.metric, latest, peak }
})
.sort((a, b) => b.latest - a.latest)
.slice(0, 3)
const overallLatest = ranked.reduce((sum, item) => sum + item.latest, 0)
return (
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Combined latest value</p>
<p className="mt-1 text-2xl font-semibold text-emerald-300">{formatNumber(overallLatest)}</p>
</div>
{ranked.map(item => (
<div
key={item.metric}
className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner"
>
<p className="text-xs uppercase tracking-wide text-slate-500">{item.metric}</p>
<p className="mt-1 text-xl font-semibold text-slate-100">{formatNumber(item.latest)}</p>
<p className="text-xs text-slate-500">Peak {formatNumber(item.peak)}</p>
</div>
))}
</div>
)
}

View File

@ -1,48 +0,0 @@
'use client'
import { TraceSpan } from '../../insight/services/adapters/traces'
interface TracesTableProps {
spans: TraceSpan[]
}
export function TracesTable({ spans }: TracesTableProps) {
if (!spans.length) {
return <p className="text-sm text-slate-400">Run a TraceQL query to review spans in a table.</p>
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm text-slate-200">
<thead className="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th scope="col" className="px-4 py-2 font-medium">
Span
</th>
<th scope="col" className="px-4 py-2 font-medium">
Service
</th>
<th scope="col" className="px-4 py-2 font-medium">
Duration (ms)
</th>
<th scope="col" className="px-4 py-2 font-medium">
Start time
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{spans.map(span => (
<tr key={span.id} className="bg-slate-950/50">
<td className="px-4 py-2 font-medium text-slate-100">{span.name}</td>
<td className="px-4 py-2 text-xs text-slate-300">{span.service}</td>
<td className="px-4 py-2 text-xs text-slate-300">{span.durationMs.toFixed(1)}</td>
<td className="px-4 py-2 text-xs text-slate-400">
{new Date(span.startTime).toLocaleTimeString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -1,42 +0,0 @@
'use client'
import { TraceSpan } from '../../insight/services/adapters/traces'
interface TracesTopStatsProps {
spans: TraceSpan[]
}
export function TracesTopStats({ spans }: TracesTopStatsProps) {
if (!spans.length) {
return <p className="text-sm text-slate-400">Run a TraceQL query to surface top spans and bottlenecks.</p>
}
const longestSpan = spans.reduce((prev, current) => (current.durationMs > prev.durationMs ? current : prev), spans[0])
const averageDuration = spans.reduce((sum, span) => sum + span.durationMs, 0) / spans.length
const serviceDurations = spans.reduce<Record<string, number>>((acc, span) => {
acc[span.service] = (acc[span.service] ?? 0) + span.durationMs
return acc
}, {})
const [heaviestService, heaviestDuration] = Object.entries(serviceDurations).sort((a, b) => b[1] - a[1])[0]
return (
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Longest span</p>
<p className="mt-1 text-xl font-semibold text-slate-100">{longestSpan.name}</p>
<p className="text-xs text-slate-500">{longestSpan.durationMs.toFixed(1)} ms</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Average duration</p>
<p className="mt-1 text-2xl font-semibold text-emerald-300">{averageDuration.toFixed(1)} ms</p>
<p className="text-xs text-slate-500">Across {spans.length} spans</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300 shadow-inner">
<p className="text-xs uppercase tracking-wide text-slate-500">Heaviest service</p>
<p className="mt-1 text-xl font-semibold text-slate-100">{heaviestService ?? 'unknown'}</p>
<p className="text-xs text-slate-500">{(heaviestDuration ?? 0).toFixed(1)} ms total</p>
</div>
</div>
)
}

View File

@ -1,41 +0,0 @@
'use client'
import { TraceSpan } from '../../insight/services/adapters/traces'
interface TracesWaterfallProps {
spans: TraceSpan[]
}
export function TracesWaterfall({ spans }: TracesWaterfallProps) {
if (!spans.length) {
return <p className="text-sm text-slate-400">Run a TraceQL query to render waterfall timelines.</p>
}
const rootStart = Math.min(...spans.map(span => span.startTime))
const totalDuration = Math.max(...spans.map(span => span.startTime + span.durationMs)) - rootStart || 1
return (
<div className="space-y-2">
{spans.map(span => {
const offset = ((span.startTime - rootStart) / totalDuration) * 100
const width = (span.durationMs / totalDuration) * 100
return (
<div key={span.id} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="font-medium text-slate-200">{span.name}</span>
<span>{span.durationMs.toFixed(1)} ms</span>
</div>
<div className="relative h-8 rounded-xl bg-slate-900/80">
<div
className="absolute top-1 h-6 rounded-xl bg-emerald-500/40"
style={{ left: `${offset}%`, width: `${Math.max(width, 4)}%` }}
>
<span className="absolute left-2 top-1 text-[11px] text-emerald-950">{span.service}</span>
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@ -1,134 +0,0 @@
'use client'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { buildCorrelatedQuery } from '../../insight/services/correlator'
import { DataSource, InsightState } from '../../insight/store/urlState'
import { MetricsChart } from './MetricsChart'
import { MetricsTable } from './MetricsTable'
import { MetricsTopStats } from './MetricsTopStats'
import { LogsViewer } from './LogsViewer'
import { LogsTable } from './LogsTable'
import { LogsTopStats } from './LogsTopStats'
import { TracesWaterfall } from './TracesWaterfall'
import { TracesTable } from './TracesTable'
import { TracesTopStats } from './TracesTopStats'
type ViewMode = 'trend' | 'table' | 'top'
interface VizAreaProps {
state: InsightState
data: any
onUpdate: (partial: Partial<InsightState>) => void
}
export function VizArea({ state, data, onUpdate }: VizAreaProps) {
const mode = state.dataSource
const [viewMode, setViewMode] = useState<ViewMode>('trend')
useEffect(() => {
setViewMode('trend')
}, [mode])
const viewOptions = useMemo(
() => [
{ id: 'trend' as ViewMode, label: 'Trend chart' },
{ id: 'table' as ViewMode, label: 'Table' },
{ id: 'top' as ViewMode, label: 'Top stats' }
],
[]
)
const title = modeLabel[mode]
function correlate(target: DataSource) {
const language: InsightState['queryLanguage'] = target === 'metrics' ? 'promql' : target === 'logs' ? 'logql' : 'traceql'
const query = buildCorrelatedQuery(target, {
service: state.service || 'checkout',
namespace: state.namespace,
timeRange: state.timeRange
})
onUpdate({
dataSource: target,
queryLanguage: language,
queries: { ...state.queries, [language]: query },
activeLanguages: Array.from(new Set([...state.activeLanguages, language]))
})
}
function renderContent(): ReactNode {
const metricsSeries = Array.isArray(data) ? data : []
const logs = Array.isArray(data) ? data : []
const traces = Array.isArray(data) ? data : []
switch (mode) {
case 'metrics':
if (viewMode === 'table') {
return <MetricsTable series={metricsSeries} />
}
if (viewMode === 'top') {
return <MetricsTopStats series={metricsSeries} />
}
return <MetricsChart series={metricsSeries} />
case 'logs':
if (viewMode === 'table') {
return <LogsTable logs={logs} />
}
if (viewMode === 'top') {
return <LogsTopStats logs={logs} />
}
return <LogsViewer logs={logs} />
case 'traces':
if (viewMode === 'table') {
return <TracesTable spans={traces} />
}
if (viewMode === 'top') {
return <TracesTopStats spans={traces} />
}
return <TracesWaterfall spans={traces} />
default:
return null
}
}
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-5 shadow-lg shadow-slate-950/20">
<div className="flex flex-wrap items-center gap-3">
{title && <h3 className="text-sm font-semibold text-slate-200">{title}</h3>}
<div className={`${title ? 'ml-auto ' : ''}flex flex-wrap items-center gap-2 text-xs text-slate-300`}>
<div className="flex overflow-hidden rounded-xl border border-slate-800">
{viewOptions.map(option => (
<button
key={option.id}
onClick={() => setViewMode(option.id)}
className={`px-3 py-1 text-xs transition ${
viewMode === option.id ? 'bg-slate-800 text-slate-100' : 'bg-slate-900/50 text-slate-400 hover:bg-slate-800'
}`}
>
{option.label}
</button>
))}
</div>
<button onClick={() => correlate('metrics')} className="rounded-xl border border-slate-800 px-3 py-1 hover:bg-slate-800">
Link to Metrics
</button>
<button onClick={() => correlate('logs')} className="rounded-xl border border-slate-800 px-3 py-1 hover:bg-slate-800">
Link to Logs
</button>
<button onClick={() => correlate('traces')} className="rounded-xl border border-slate-800 px-3 py-1 hover:bg-slate-800">
Link to Traces
</button>
<button className="rounded-xl bg-slate-200/10 px-3 py-1 text-slate-200">Save to dashboard</button>
</div>
</div>
<div className="mt-4 min-h-[240px] rounded-2xl border border-slate-800 bg-slate-950/60 p-4 shadow-inner">
{renderContent()}
</div>
</div>
)
}
const modeLabel: Record<DataSource, string> = {
metrics: '',
logs: 'Log stream',
traces: 'Trace waterfall'
}

View File

@ -1,377 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Layout } from 'react-grid-layout'
import { ChevronLeft, ChevronRight, PanelLeftOpen } from 'lucide-react'
import { Sidebar } from '../components/insight/layout/Sidebar'
import { WorkspaceHeader } from '../components/insight/layout/WorkspaceHeader'
import { BreadcrumbBar } from '../components/insight/layout/BreadcrumbBar'
import { WorkspaceGrid } from '../components/insight/layout/WorkspaceGrid'
import { NetworkTopologyPanel } from '../components/insight/topology/NetworkTopologyPanel'
import { ExploreBuilder, languageMeta } from '../components/insight/explore/ExploreBuilder'
import { VizArea } from '../components/insight/viz/VizArea'
import { SLOPanel } from '../components/insight/slo/SLOPanel'
import { AIAssistant } from '../components/insight/ai/Assistant'
import { useInsightStore } from '../components/insight/store/useInsightState'
import { DataSource, QueryLanguage } from '../components/insight/store/urlState'
const LAYOUT_STORAGE_KEY = 'insight-workspace-layout-v1'
const DEFAULT_LAYOUT: Layout[] = [
{ i: 'network', x: 0, y: 0, w: 6, h: 8, minW: 4, minH: 6 },
{ i: 'promql', x: 6, y: 0, w: 6, h: 8, minW: 4, minH: 6 },
{ i: 'logql', x: 0, y: 8, w: 6, h: 8, minW: 4, minH: 6 },
{ i: 'traceql', x: 6, y: 8, w: 6, h: 8, minW: 4, minH: 6 }
]
export default function InsightWorkbench() {
const state = useInsightStore((store) => store.state)
const updateState = useInsightStore((store) => store.updateInsight)
const shareableLink = useInsightStore((store) => store.shareableLink)
const [activeSection, setActiveSection] = useState('topology')
const [history, setHistory] = useState<Record<QueryLanguage, string[]>>({
promql: [],
logql: [],
traceql: []
})
const [resultData, setResultData] = useState<Record<QueryLanguage, any>>({
promql: [],
logql: [],
traceql: []
})
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [sidebarHidden, setSidebarHidden] = useState(false)
const [panelLayout, setPanelLayout] = useState<Layout[]>(() => DEFAULT_LAYOUT.map(item => ({ ...item })))
const [layoutDirty, setLayoutDirty] = useState(false)
const [layoutStatus, setLayoutStatus] = useState<string | null>(null)
const [detailsCollapsed, setDetailsCollapsed] = useState(false)
const statusTimeout = useRef<number | null>(null)
const handleSelectSection = useCallback((section: string) => {
setActiveSection(section)
const el = document.getElementById(section)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, [])
const updateHistory = useCallback(
(language: QueryLanguage, items: string[]) => {
setHistory(prev => ({ ...prev, [language]: items }))
},
[]
)
const updateResults = useCallback((language: QueryLanguage, data: any) => {
setResultData(prev => ({ ...prev, [language]: data }))
}, [])
const toggleLanguage = useCallback(
(language: QueryLanguage) => {
const exists = state.activeLanguages.includes(language)
let nextActive = exists
? state.activeLanguages.filter(item => item !== language)
: [...state.activeLanguages, language]
if (nextActive.length === 0) {
nextActive = [language]
}
const primary = nextActive[0]
const nextSource: DataSource = primary === 'promql' ? 'metrics' : primary === 'logql' ? 'logs' : 'traces'
updateState({
activeLanguages: nextActive,
queryLanguage: primary,
dataSource: nextSource
})
},
[state.activeLanguages, updateState]
)
const handleLayoutChange = useCallback((next: Layout[]) => {
setPanelLayout(next)
setLayoutDirty(true)
}, [])
const resetStatusMessage = useCallback(() => {
if (statusTimeout.current) {
window.clearTimeout(statusTimeout.current)
statusTimeout.current = null
}
}, [])
const handleSaveLayout = useCallback(() => {
if (typeof window === 'undefined') return
window.localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(panelLayout))
setLayoutDirty(false)
setLayoutStatus('Layout saved locally')
resetStatusMessage()
statusTimeout.current = window.setTimeout(() => setLayoutStatus(null), 2200)
}, [panelLayout, resetStatusMessage])
const handleResetLayout = useCallback(() => {
setPanelLayout(DEFAULT_LAYOUT.map(item => ({ ...item })))
setLayoutDirty(false)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(LAYOUT_STORAGE_KEY)
}
setLayoutStatus('Layout reset to default')
resetStatusMessage()
statusTimeout.current = window.setTimeout(() => setLayoutStatus(null), 2200)
}, [resetStatusMessage])
useEffect(() => {
if (typeof window === 'undefined') return
const stored = window.localStorage.getItem(LAYOUT_STORAGE_KEY)
if (!stored) return
try {
const parsed = JSON.parse(stored) as Layout[]
if (!Array.isArray(parsed)) return
const merged = DEFAULT_LAYOUT.map(item => {
const match = parsed.find(entry => entry.i === item.i)
return match ? { ...item, ...match } : { ...item }
})
setPanelLayout(merged)
setLayoutDirty(false)
} catch (error) {
console.error('Failed to restore insight layout', error)
}
}, [])
useEffect(() => {
return () => {
if (statusTimeout.current) {
window.clearTimeout(statusTimeout.current)
}
}
}, [])
const keyMetrics = useMemo(
() => [
{
label: 'Availability',
value: state.topologyMode === 'network' ? '99.96%' : '99.90%',
trend: '+0.3% vs last 7d',
tone: 'positive' as const
},
{
label: 'P95 latency',
value: state.topologyMode === 'network' ? '82 ms' : '248 ms',
trend: `${state.timeRange} window`,
tone: 'neutral' as const
},
{
label: 'Error rate',
value: state.topologyMode === 'application' ? '0.7%' : '0.4%',
trend: 'Target < 1%',
tone: 'warning' as const
}
],
[state.timeRange, state.topologyMode]
)
const explorerPanels = (language: QueryLanguage, domId?: string) => {
const enabled = state.activeLanguages.includes(language)
return {
id: language,
domId,
minW: 4,
minH: 6,
content: enabled ? (
<ExploreBuilder
state={state}
updateState={updateState}
history={history}
setHistory={updateHistory}
onResults={updateResults}
panelLanguages={[language]}
/>
) : (
<DisabledExplorerCard language={language} onEnable={() => toggleLanguage(language)} />
)
}
}
const panels = [
{
id: 'network',
domId: 'topology',
minW: 4,
minH: 6,
content: <NetworkTopologyPanel state={state} updateState={updateState} />
},
explorerPanels('promql', 'explore'),
explorerPanels('logql'),
explorerPanels('traceql')
]
const insightAsideWidth = detailsCollapsed ? 'lg:w-60 xl:w-64' : 'lg:w-80 xl:w-96'
return (
<div className="relative flex min-h-screen flex-col bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-slate-100">
{sidebarHidden && (
<button
type="button"
onClick={() => setSidebarHidden(false)}
className="fixed left-4 top-4 z-20 flex items-center gap-2 rounded-full border border-slate-800 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 shadow-lg backdrop-blur transition hover:border-slate-700 hover:text-slate-100"
>
<PanelLeftOpen className="h-4 w-4" />
Show menu
</button>
)}
<header className="flex-shrink-0 border-b border-slate-800 bg-slate-950/70">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-4 lg:flex-row lg:items-center lg:justify-between lg:px-8">
<BreadcrumbBar state={state} updateState={updateState} shareableLink={shareableLink} />
<div className="flex flex-wrap items-center gap-2 text-xs">
<button
type="button"
onClick={handleSaveLayout}
disabled={!layoutDirty}
className={`rounded-xl px-3 py-2 font-medium transition ${
layoutDirty
? 'border border-emerald-500/60 bg-emerald-500/10 text-emerald-200 hover:border-emerald-400/80'
: 'border border-slate-800 bg-slate-900/70 text-slate-500'
}`}
>
Save layout
</button>
<button
type="button"
onClick={handleResetLayout}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2 font-medium text-slate-300 transition hover:border-slate-700 hover:text-slate-100"
>
Reset to default
</button>
</div>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
{!sidebarHidden && (
<div className="flex-shrink-0">
<Sidebar
topologyMode={state.topologyMode}
activeLanguages={state.activeLanguages}
activeSection={activeSection}
onSelectSection={handleSelectSection}
onTopologyChange={mode => updateState({ topologyMode: mode })}
onToggleLanguage={toggleLanguage}
onToggleCollapse={() => setSidebarCollapsed(prev => !prev)}
onHide={() => setSidebarHidden(true)}
collapsed={sidebarCollapsed}
/>
</div>
)}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto px-4 py-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 lg:flex-row lg:items-start lg:gap-8">
<main className="flex-1 space-y-6">
<WorkspaceHeader
state={state}
updateState={updateState}
shareableLink={shareableLink}
statusMessage={layoutStatus}
showBreadcrumb={false}
/>
<div id="explore">
<WorkspaceGrid
layout={panelLayout}
defaultLayout={DEFAULT_LAYOUT}
panels={panels}
onLayoutChange={handleLayoutChange}
draggableHandle=".panel-drag-handle"
/>
</div>
<section id="visualize">
<VizArea state={state} data={resultData[state.queryLanguage]} onUpdate={updateState} />
</section>
</main>
<aside
className={`mt-6 w-full flex-shrink-0 lg:mt-0 ${insightAsideWidth} lg:self-stretch lg:overflow-y-auto`}
>
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<button
type="button"
onClick={() => setDetailsCollapsed(prev => !prev)}
className="flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-1 text-xs text-slate-300 transition hover:border-slate-700 hover:text-slate-100"
>
{detailsCollapsed ? (
<>
<ChevronLeft className="h-4 w-4" /> Expand insights
</>
) : (
<>
Collapse insights <ChevronRight className="h-4 w-4" />
</>
)}
</button>
</div>
{detailsCollapsed ? (
<div className="flex flex-col gap-3 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 text-xs text-slate-300">
<h3 className="text-sm font-semibold text-slate-100">Key health metrics</h3>
<p className="text-[11px] text-slate-500">Pinned while the panel is collapsed for quick status checks.</p>
<div className="grid gap-3">
{keyMetrics.map(metric => (
<div
key={metric.label}
className="rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2"
>
<p className="text-[11px] uppercase tracking-wide text-slate-500">{metric.label}</p>
<p className="text-lg font-semibold text-slate-100">{metric.value}</p>
<p
className={`text-[11px] ${
metric.tone === 'positive'
? 'text-emerald-300'
: metric.tone === 'warning'
? 'text-amber-300'
: 'text-slate-400'
}`}
>
{metric.trend}
</p>
</div>
))}
</div>
</div>
) : (
<div className="space-y-6">
<section id="slo">
<SLOPanel state={state} />
</section>
<section id="ai">
<AIAssistant state={state} />
</section>
</div>
)}
</div>
</aside>
</div>
</div>
</div>
</div>
</div>
)
}
interface DisabledExplorerCardProps {
language: QueryLanguage
onEnable: () => void
}
function DisabledExplorerCard({ language, onEnable }: DisabledExplorerCardProps) {
const meta = languageMeta[language]
return (
<section className="flex h-full flex-col rounded-2xl border border-dashed border-slate-800 bg-slate-900/40 p-5">
<header className="panel-drag-handle mb-4">
<h3 className="text-sm font-semibold text-slate-200">{meta.label}</h3>
<p className="text-xs text-slate-400">Enable this explorer from the navigation to build queries.</p>
</header>
<div className="flex flex-1 flex-col items-start justify-center gap-4 text-sm text-slate-400">
<p>Capture metrics, logs or traces by toggling the language on the left-hand menu.</p>
<button
type="button"
onClick={onEnable}
className="rounded-xl border border-emerald-500/60 bg-emerald-500/10 px-3 py-2 text-xs font-medium text-emerald-200 transition hover:border-emerald-400/80"
>
Enable {meta.label.split(' ')[0]}
</button>
</div>
</section>
)
}

View File

@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function InsightPage() {
redirect('https://infra.svc.plus/')
}

View File

@ -49,11 +49,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${baseUrl}/insight`,
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${baseUrl}/cloud_iac`,
changeFrequency: 'monthly',