feat: remove the entire insight feature and its related components.
This commit is contained in:
parent
b58098aa65
commit
2f34589a54
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 ''
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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']
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function InsightPage() {
|
||||
redirect('https://infra.svc.plus/')
|
||||
}
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user