Files
cloudlysis/control/ui/src/app/layout.tsx
Vlad Durnea beae85d2d2
Some checks failed
ci / ui (push) Failing after 31s
images / build-and-push (push) Failing after 19s
ci / rust (push) Failing after 2m38s
feat(control-ui): modernize UX with premium Ultrabase design system
2026-03-30 21:57:33 +03:00

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>
)
}