184 lines
5.9 KiB
TypeScript
184 lines
5.9 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { Link, Outlet, useLocation } from 'react-router-dom'
|
|
import { getLastRequestIds } from '../api/client'
|
|
import { Button, Code, TextInput } from '../components/primitives'
|
|
|
|
type NavItem = {
|
|
label: string
|
|
to: string
|
|
}
|
|
|
|
const navItems: NavItem[] = [
|
|
{ label: 'Overview', to: '/' },
|
|
{ label: 'Tenants', to: '/tenants' },
|
|
{ label: 'Users', to: '/users' },
|
|
{ label: 'Sessions', to: '/sessions' },
|
|
{ label: 'Roles & Permissions', to: '/roles-permissions' },
|
|
{ label: 'Config', to: '/config' },
|
|
{ label: 'Definitions', to: '/definitions' },
|
|
{ label: 'Scale & Placement', to: '/scale-placement' },
|
|
{ label: 'Deployments', to: '/deployments' },
|
|
{ label: 'Observability', to: '/observability' },
|
|
{ label: 'Audit Log', to: '/audit-log' },
|
|
{ label: 'Settings', to: '/settings' },
|
|
]
|
|
|
|
function normalizePath(pathname: string) {
|
|
if (pathname === '') return '/'
|
|
if (pathname === '/') return '/'
|
|
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
|
|
}
|
|
|
|
export function Layout() {
|
|
const location = useLocation()
|
|
const active = normalizePath(location.pathname)
|
|
const [query, setQuery] = useState('')
|
|
const lastIds = getLastRequestIds()
|
|
|
|
const grafana = useMemo(() => {
|
|
const base = (import.meta.env.VITE_GRAFANA_URL as string | undefined) ?? ''
|
|
const loki = (import.meta.env.VITE_GRAFANA_LOKI_DATASOURCE as string | undefined) ?? 'Loki'
|
|
const tempo = (import.meta.env.VITE_GRAFANA_TEMPO_DATASOURCE as string | undefined) ?? 'Tempo'
|
|
return { base, loki, tempo }
|
|
}, [])
|
|
|
|
function openGrafanaLogs(id: string) {
|
|
if (!grafana.base) return
|
|
const left = encodeURIComponent(
|
|
JSON.stringify({
|
|
datasource: grafana.loki,
|
|
queries: [{ refId: 'A', expr: `{correlation_id="${id}"}` }],
|
|
}),
|
|
)
|
|
window.open(`${grafana.base.replace(/\/$/, '')}/explore?left=${left}`, '_blank', 'noreferrer')
|
|
}
|
|
|
|
function openGrafanaTrace(id: string) {
|
|
if (!grafana.base) return
|
|
const left = encodeURIComponent(
|
|
JSON.stringify({
|
|
datasource: grafana.tempo,
|
|
queries: [{ refId: 'A', queryType: 'traceId', traceId: id }],
|
|
}),
|
|
)
|
|
window.open(`${grafana.base.replace(/\/$/, '')}/explore?left=${left}`, '_blank', 'noreferrer')
|
|
}
|
|
|
|
async function copy(text: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: 'system-ui, sans-serif' }}>
|
|
<aside
|
|
style={{
|
|
width: 260,
|
|
borderRight: '1px solid #eee',
|
|
padding: 16,
|
|
background: '#fafafa',
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 700, marginBottom: 16 }}>Cloudlysis Control</div>
|
|
<nav style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{navItems.map((item) => {
|
|
const isActive = active === normalizePath(item.to)
|
|
return (
|
|
<Link
|
|
key={item.to}
|
|
to={item.to}
|
|
style={{
|
|
textDecoration: 'none',
|
|
color: '#111',
|
|
padding: '6px 10px',
|
|
borderRadius: 8,
|
|
background: isActive ? '#eaeaea' : 'transparent',
|
|
}}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
)
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
|
|
<div style={{ flex: 1 }}>
|
|
<header
|
|
style={{
|
|
borderBottom: '1px solid #eee',
|
|
padding: 16,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
|
<div style={{ width: 420, maxWidth: '100%' }}>
|
|
<TextInput
|
|
ariaLabel="Global search"
|
|
placeholder="Search request/correlation/trace id"
|
|
value={query}
|
|
onChange={setQuery}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
const id = query.trim()
|
|
if (!id) return
|
|
openGrafanaLogs(id)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={() => {
|
|
const id = query.trim()
|
|
if (!id) return
|
|
openGrafanaLogs(id)
|
|
}}
|
|
disabled={!grafana.base}
|
|
>
|
|
Logs
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
const id = query.trim()
|
|
if (!id) return
|
|
openGrafanaTrace(id)
|
|
}}
|
|
disabled={!grafana.base}
|
|
>
|
|
Trace
|
|
</Button>
|
|
</div>
|
|
|
|
{lastIds ? (
|
|
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<span style={{ fontSize: 12, color: '#666' }}>request_id</span>
|
|
<Code>{lastIds.requestId}</Code>
|
|
<Button onClick={() => copy(lastIds.requestId)}>Copy</Button>
|
|
</div>
|
|
{lastIds.correlationId ? (
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<span style={{ fontSize: 12, color: '#666' }}>correlation_id</span>
|
|
<Code>{lastIds.correlationId}</Code>
|
|
<Button onClick={() => copy(lastIds.correlationId ?? '')}>Copy</Button>
|
|
<Button
|
|
onClick={() => openGrafanaLogs(lastIds.correlationId ?? '')}
|
|
disabled={!grafana.base}
|
|
>
|
|
Investigate
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</header>
|
|
<Outlet />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|