Monorepo consolidation: workspace, shared types, transport plans, docker/swam assets
This commit is contained in:
183
control/ui/src/app/layout.tsx
Normal file
183
control/ui/src/app/layout.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user