359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import React, { useMemo, useState } from 'react'
|
|
import { Link, Outlet, useLocation } from 'react-router-dom'
|
|
import { getLastRequestIds } from '../api/client'
|
|
import { Button, Code, TextInput } from '../components/primitives'
|
|
import { Icons } from '../components/ui/Icons'
|
|
|
|
type NavItem = {
|
|
label: string
|
|
to: string
|
|
icon: React.ComponentType<any>
|
|
}
|
|
|
|
type NavCategory = {
|
|
label: string
|
|
items: NavItem[]
|
|
}
|
|
|
|
const navCategories: NavCategory[] = [
|
|
{
|
|
label: 'Platform',
|
|
items: [
|
|
{ label: 'Overview', to: '/', icon: Icons.Activity },
|
|
{ label: 'Tenants', to: '/tenants', icon: Icons.Tenants },
|
|
{ label: 'Fleet Nodes', to: '/fleet', icon: Icons.Fleet },
|
|
{ label: 'Observability', to: '/observability', icon: Icons.Observability },
|
|
],
|
|
},
|
|
{
|
|
label: 'Infrastructure',
|
|
items: [
|
|
{ label: 'Scale & Placement', to: '/scale-placement', icon: Icons.Topology },
|
|
{ label: 'Deployments', to: '/deployments', icon: Icons.Deployments },
|
|
],
|
|
},
|
|
{
|
|
label: 'Resources',
|
|
items: [
|
|
{ label: 'Config Registry', to: '/config', icon: Icons.Config },
|
|
{ label: 'Type Definitions', to: '/definitions', icon: Icons.Definitions },
|
|
{ label: 'Documents', to: '/documents', icon: Icons.Storage },
|
|
],
|
|
},
|
|
{
|
|
label: 'Governance',
|
|
items: [
|
|
{ label: 'Audit Log', to: '/audit-log', icon: Icons.Audit },
|
|
{ label: 'Platform Drift', to: '/drift', icon: Icons.Drift },
|
|
],
|
|
},
|
|
{
|
|
label: 'Management',
|
|
items: [
|
|
{ label: 'Users', to: '/users', icon: Icons.Users },
|
|
{ label: 'Sessions', to: '/sessions', icon: Icons.Sessions },
|
|
{ label: 'Roles & Permissions', to: '/roles-permissions', icon: Icons.ShieldAlert },
|
|
{ label: 'Settings', to: '/settings', icon: Icons.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 activePath = 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
|
|
}
|
|
}
|
|
|
|
const NavLink = ({ item }: { item: NavItem }) => {
|
|
const active = activePath === normalizePath(item.to)
|
|
const Icon = item.icon
|
|
return (
|
|
<Link
|
|
to={item.to}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
padding: '8px 12px',
|
|
borderRadius: 'var(--radius-md)',
|
|
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
background: active ? 'var(--bg-tertiary)' : 'transparent',
|
|
fontWeight: active ? 500 : 400,
|
|
textDecoration: 'none',
|
|
fontSize: '14px',
|
|
transition: 'all 0.2s ease',
|
|
}}
|
|
onMouseOver={(e) => {
|
|
if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.05)'
|
|
}}
|
|
onMouseOut={(e) => {
|
|
if (!active) e.currentTarget.style.background = 'transparent'
|
|
}}
|
|
>
|
|
<span style={{ color: active ? 'var(--accent-primary)' : 'inherit', display: 'flex' }}>
|
|
<Icon />
|
|
</span>
|
|
{item.label}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--bg-primary)' }}>
|
|
{/* Sidebar */}
|
|
<aside
|
|
style={{
|
|
width: 240,
|
|
borderRight: '1px solid var(--border-primary)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
background: 'var(--bg-primary)',
|
|
position: 'sticky',
|
|
top: 0,
|
|
height: '100vh',
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: '24px',
|
|
borderBottom: '1px solid var(--border-primary)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 32,
|
|
height: 32,
|
|
background: 'var(--accent-primary)',
|
|
borderRadius: 'var(--radius-md)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: '#000',
|
|
}}
|
|
>
|
|
<Icons.Zap />
|
|
</div>
|
|
<span style={{ fontWeight: 700, fontSize: '18px', letterSpacing: '-0.02em' }}>
|
|
Cloudlysis
|
|
</span>
|
|
</div>
|
|
|
|
<nav
|
|
style={{
|
|
flex: 1,
|
|
padding: '20px 12px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '20px',
|
|
overflowY: 'auto',
|
|
}}
|
|
>
|
|
{navCategories.map((cat) => (
|
|
<div key={cat.label} style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
<div
|
|
style={{
|
|
padding: '0 12px',
|
|
marginBottom: '4px',
|
|
fontSize: '11px',
|
|
fontWeight: 600,
|
|
color: 'var(--text-muted)',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
}}
|
|
>
|
|
{cat.label}
|
|
</div>
|
|
{cat.items.map((item) => (
|
|
<NavLink key={item.to} item={item} />
|
|
))}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
|
|
<div
|
|
style={{
|
|
padding: '16px',
|
|
borderTop: '1px solid var(--border-primary)',
|
|
background: 'var(--bg-secondary)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
<div
|
|
style={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: '50%',
|
|
background: 'var(--bg-tertiary)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
OP
|
|
</div>
|
|
<div style={{ overflow: 'hidden' }}>
|
|
<div
|
|
style={{
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
Operator
|
|
</div>
|
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Administrator</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
|
<header
|
|
style={{
|
|
borderBottom: '1px solid var(--border-primary)',
|
|
padding: '16px 32px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '16px',
|
|
background: 'var(--bg-primary)',
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 5,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<div style={{ width: 480, maxWidth: '100%' }}>
|
|
<TextInput
|
|
placeholder="Search correlation/trace id..."
|
|
value={query}
|
|
onChange={setQuery}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
const id = query.trim()
|
|
if (!id) return
|
|
openGrafanaLogs(id)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => {
|
|
const id = query.trim()
|
|
if (!id) return
|
|
openGrafanaLogs(id)
|
|
}}
|
|
disabled={!grafana.base}
|
|
icon={<Icons.Activity style={{ width: 14, height: 14 }} />}
|
|
>
|
|
Logs
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => {
|
|
const id = query.trim()
|
|
if (!id) return
|
|
openGrafanaTrace(id)
|
|
}}
|
|
disabled={!grafana.base}
|
|
icon={<Icons.Topology style={{ width: 14, height: 14 }} />}
|
|
>
|
|
Trace
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{lastIds && (
|
|
<div style={{ display: 'flex', gap: '24px', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>request_id</span>
|
|
<Code>{lastIds.requestId}</Code>
|
|
<Button variant="ghost" size="sm" onClick={() => copy(lastIds.requestId)} style={{ padding: '2px 4px' }}>
|
|
Copy
|
|
</Button>
|
|
</div>
|
|
{lastIds.correlationId && (
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>correlation_id</span>
|
|
<Code>{lastIds.correlationId}</Code>
|
|
<Button variant="ghost" size="sm" onClick={() => copy(lastIds.correlationId || '')} style={{ padding: '2px 4px' }}>
|
|
Copy
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => openGrafanaLogs(lastIds.correlationId || '')}
|
|
disabled={!grafana.base}
|
|
style={{ fontSize: '11px', padding: '2px 8px' }}
|
|
>
|
|
Investigate
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
<main style={{ padding: '32px', flex: 1 }}>
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|