91ce11274d
CI / checks (pull_request) Successful in 25m47s
Labels, empty states, error messages and defaults the console shows now name TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_* settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
363 lines
16 KiB
TypeScript
363 lines
16 KiB
TypeScript
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
|
import {Activity, AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
|
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
|
import theCloud from '../../../../TheCloud.png'
|
|
import {useTheme} from '@/lib/useTheme'
|
|
import {useSidebar} from '@/lib/useSidebar'
|
|
import {useQuery} from '@tanstack/react-query'
|
|
import {getCloudStatus} from '@/api/cloudProxyClient'
|
|
import {useCloudServicesQuery} from '@/api/queries/cloudQueries'
|
|
import {AccountSwitcher} from '@/components/AccountSwitcher'
|
|
import {ServiceIcon} from '@/components/serviceIcons'
|
|
import type {CloudProvider, CloudServiceDescriptor, RuntimeReachability} from '@/types/cloud'
|
|
|
|
/** The runtime's reachability, plus the state before the status query answers. */
|
|
type ConnectionStatus = RuntimeReachability | 'unknown'
|
|
|
|
/**
|
|
* Only a definitive answer gets a colour.
|
|
*
|
|
* `unknown` (status query still in flight — which is first paint and every
|
|
* cloud switch) and `coming_soon` (runtime not wired) keep the neutral base
|
|
* grey. Colouring anything that is not `reachable` red made the dot flash
|
|
* green → red → green on each switch, and start red on load.
|
|
*/
|
|
function connectionDotClass(status: ConnectionStatus): string {
|
|
if (status === 'reachable') return 'dot healthy'
|
|
if (status === 'unavailable') return 'dot unavailable'
|
|
return 'dot'
|
|
}
|
|
|
|
/** Matches today's service count, so the real nav causes no layout jump. */
|
|
const SKELETON_ROWS = 7
|
|
|
|
function NavItem({to, icon, label, collapsed}: { to: string; icon: React.ReactNode; label: string; collapsed: boolean }) {
|
|
return (
|
|
<NavLink className="nav-link" to={to} title={collapsed ? label : undefined}>
|
|
{icon}
|
|
<span>{label}</span>
|
|
</NavLink>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The nav is rendered entirely from `GET /clouds/:cloud/services`.
|
|
*
|
|
* It used to be a hardcoded item list plus a per-cloud boolean that never
|
|
* consulted the server, so registering an adapter did not light up the nav and
|
|
* availability could disagree with the API. Adding a service is now a catalog
|
|
* row on the server and nothing here.
|
|
*/
|
|
function CloudServiceNav({collapsed}: {collapsed: boolean}) {
|
|
const location = useLocation()
|
|
const [searchParams] = useSearchParams()
|
|
const search = (searchParams.get('search') ?? '').trim().toLowerCase()
|
|
const cloud = activeCloudFromPath(location.pathname)
|
|
const cloudLabel = cloud.toUpperCase()
|
|
const {data, isPending, isError, refetch, isFetching} = useCloudServicesQuery(cloud)
|
|
|
|
if (isPending) return <CloudServiceNavSkeleton cloudLabel={cloudLabel}/>
|
|
|
|
if (isError) {
|
|
return (
|
|
<div className="nav-section cloud-service-nav">
|
|
<span className="nav-label">Cloud Services · {cloudLabel}</span>
|
|
<div className="nav-link disabled nav-error">
|
|
<AlertTriangle size={14} aria-hidden="true"/>
|
|
<span>Services unavailable</span>
|
|
</div>
|
|
<button className="nav-retry" type="button" disabled={isFetching} onClick={() => void refetch()}>
|
|
{isFetching ? 'Retrying…' : 'Retry'}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const allServices = data ?? []
|
|
const filteredServices = search
|
|
? allServices.filter((s) =>
|
|
s.displayName.toLowerCase().includes(search) ||
|
|
s.service.toLowerCase().includes(search) ||
|
|
s.group.toLowerCase().includes(search)
|
|
)
|
|
: allServices
|
|
|
|
const groups = groupByGroup(filteredServices)
|
|
|
|
return (
|
|
<div className="nav-section cloud-service-nav">
|
|
<span className="nav-label">Cloud Services · {cloudLabel}</span>
|
|
{filteredServices.length === 0 && search ? (
|
|
<div className="nav-link disabled">
|
|
<span>No matching services</span>
|
|
</div>
|
|
) : (
|
|
groups.map(([group, services]) => (
|
|
<div className="nav-group" key={group}>
|
|
<span className="nav-group-label">{group}</span>
|
|
{services.map((service) => (
|
|
<CloudServiceNavItem key={service.service} cloud={cloud} service={service} collapsed={collapsed}/>
|
|
))}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CloudServiceNavItem({cloud, service, collapsed}: {cloud: CloudProvider; service: CloudServiceDescriptor; collapsed: boolean}) {
|
|
const reasonTitle = service.reason ? `${service.displayName} — ${service.reason}` : service.displayName
|
|
const icon = <ServiceIcon cloud={cloud} iconKey={service.iconKey} size={22}/>
|
|
|
|
if (service.availability === 'available') {
|
|
const target = service.route.startsWith('/')
|
|
? service.route
|
|
: `/cloud-explorer/${cloud}/${service.route}`
|
|
return <NavItem to={target} icon={icon} label={service.displayName} collapsed={collapsed}/>
|
|
}
|
|
|
|
// Title duplicates the visible label when the sidebar is expanded, which WAVE
|
|
// flags as redundant. Keep it on the rail, where the label is hidden.
|
|
return (
|
|
<div
|
|
className="nav-link disabled"
|
|
title={collapsed || service.reason ? reasonTitle : undefined}
|
|
>
|
|
{icon}
|
|
<span>{service.displayName}</span>
|
|
<span className="nav-soon">Soon</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CloudServiceNavSkeleton({cloudLabel}: {cloudLabel: string}) {
|
|
return (
|
|
<div className="nav-section cloud-service-nav">
|
|
<span className="nav-label">Cloud Services · {cloudLabel}</span>
|
|
{Array.from({length: SKELETON_ROWS}, (_, index) => (
|
|
<div className="nav-link nav-skeleton" key={index} aria-hidden="true">
|
|
<span className="skeleton-bar"/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Preserves the server's ordering while bucketing into its groups. */
|
|
function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudServiceDescriptor[]]> {
|
|
const groups = new Map<string, CloudServiceDescriptor[]>()
|
|
for (const service of services) {
|
|
const existing = groups.get(service.group)
|
|
if (existing) existing.push(service)
|
|
else groups.set(service.group, [service])
|
|
}
|
|
return [...groups]
|
|
}
|
|
|
|
export function Layout() {
|
|
const location = useLocation()
|
|
const activeCloud = activeCloudFromPath(location.pathname)
|
|
const {theme, setTheme} = useTheme()
|
|
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
|
|
const {data, isError} = useQuery({
|
|
queryKey: ['cloud-status', activeCloud],
|
|
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
|
|
refetchInterval: 5000
|
|
})
|
|
const status: ConnectionStatus = isError ? 'unavailable' : data?.runtime ?? 'unknown'
|
|
const isConnected = status === 'reachable'
|
|
const connectionLabel = isConnected ? 'Connected' : 'Not connected'
|
|
const connectionTarget = data?.endpoint ?? activeCloud
|
|
|
|
return (
|
|
<div className="app">
|
|
<a className="skip-link" href="#main-content">Skip to content</a>
|
|
<aside className="sidebar">
|
|
<div className="sidebar-inner">
|
|
<NavLink className="brand" to={`/console/${activeCloud}`} aria-label="The Cloud console home">
|
|
<img className="brand-logo" src={theCloud} alt="The Cloud"/>
|
|
<img className="brand-mark" src={theCloud} alt="" aria-hidden="true"/>
|
|
<p>The Cloud</p>
|
|
</NavLink>
|
|
|
|
<nav className="nav" aria-label="Console">
|
|
<div className="nav-section">
|
|
<span className="nav-label">General</span>
|
|
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
|
<NavItem to={`/diagnostics/${activeCloud}`} icon={<Activity size={18} aria-hidden="true"/>} label="Diagnostics" collapsed={collapsed}/>
|
|
</div>
|
|
<CloudServiceNav collapsed={collapsed}/>
|
|
</nav>
|
|
|
|
<div className="sidebar-footer">
|
|
<span className="sidebar-footer-text">TheCloud · Local</span>
|
|
<button
|
|
ref={toggleRef}
|
|
className="icon-btn"
|
|
type="button"
|
|
onClick={toggleSidebar}
|
|
aria-expanded={!collapsed}
|
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
>
|
|
{collapsed ? <ChevronsRight size={14}/> : <ChevronsLeft size={14}/>}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<div className="shell">
|
|
<header className="topbar">
|
|
<TopbarSearch/>
|
|
<div className="theme-switcher" role="group" aria-label="Console theme">
|
|
<button className={`icon-btn ${theme === 'dark' ? 'active' : ''}`} type="button" onClick={() => setTheme('dark')} title="Dark theme" aria-label="Dark theme" aria-pressed={theme === 'dark'}><Moon size={14} aria-hidden="true"/></button>
|
|
<button className={`icon-btn ${theme === 'light' ? 'active' : ''}`} type="button" onClick={() => setTheme('light')} title="Light theme" aria-label="Light theme" aria-pressed={theme === 'light'}><Sun size={14} aria-hidden="true"/></button>
|
|
<button className={`icon-btn ${theme === 'cyber' ? 'active' : ''}`} type="button" onClick={() => setTheme('cyber')} title="Terminal theme" aria-label="Terminal theme" aria-pressed={theme === 'cyber'}><Terminal size={14} aria-hidden="true"/></button>
|
|
</div>
|
|
<div id="topbar-status" className="topbar-status"/>
|
|
<AccountSwitcher/>
|
|
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
|
|
<LogOut size={14}/>
|
|
</button>
|
|
<div className={`connection ${isConnected ? 'connected' : 'disconnected'}`}>
|
|
<span className={connectionDotClass(status)} aria-hidden="true"/>
|
|
<span className="connection-state">{connectionLabel}</span>
|
|
<span className="connection-target">{connectionTarget}</span>
|
|
</div>
|
|
</header>
|
|
<main id="main-content" className="main" tabIndex={-1}>
|
|
<Outlet/>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
async function signOut(): Promise<void> {
|
|
const csrf = document.cookie.split('; ').find((item) => item.startsWith('floci_ui_csrf='))?.split('=')[1]
|
|
await fetch('/api/auth/logout', {method: 'POST', headers: csrf ? {'X-Floci-CSRF': decodeURIComponent(csrf)} : {}})
|
|
window.location.assign('/login')
|
|
}
|
|
|
|
/**
|
|
* Topbar search bar.
|
|
*
|
|
* Reads the initial value from the `search` URL query parameter so the input
|
|
* survives page refreshes and back/forward navigation. Writes back with a
|
|
* 300 ms debounce so the URL is not updated on every keystroke.
|
|
*
|
|
* Pressing `/` when no other focusable input is active focuses this bar,
|
|
* matching the keyboard hint shown in the widget.
|
|
* Pressing Escape clears the query and blurs the input.
|
|
*/
|
|
function TopbarSearch() {
|
|
const location = useLocation()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const initialQuery = searchParams.get('search') ?? ''
|
|
const [draft, setDraft] = useState(initialQuery)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
const commit = useCallback((value: string) => {
|
|
setSearchParams(
|
|
(prev) => {
|
|
const next = new URLSearchParams(prev)
|
|
if (value) {
|
|
next.set('search', value)
|
|
} else {
|
|
next.delete('search')
|
|
}
|
|
return next
|
|
},
|
|
{replace: true},
|
|
)
|
|
}, [setSearchParams])
|
|
|
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = event.target.value
|
|
setDraft(value)
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
debounceRef.current = setTimeout(() => commit(value), 300)
|
|
}
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (event.key === 'Escape') {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current)
|
|
debounceRef.current = null
|
|
}
|
|
setDraft('')
|
|
commit('')
|
|
inputRef.current?.blur()
|
|
}
|
|
}
|
|
|
|
/** Clean up pending debounce timer on unmount. */
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current)
|
|
debounceRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
/** Clear pending debounce and restore draft from destination URL search param on route change. */
|
|
useEffect(() => {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current)
|
|
debounceRef.current = null
|
|
}
|
|
const urlSearch = searchParams.get('search') ?? ''
|
|
setDraft(urlSearch)
|
|
}, [location.pathname, searchParams])
|
|
|
|
/** Focus on `/` when no other input/textarea/select is active. */
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== '/') return
|
|
const tag = (event.target as HTMLElement).tagName
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return
|
|
event.preventDefault()
|
|
inputRef.current?.focus()
|
|
}
|
|
window.addEventListener('keydown', onKeyDown)
|
|
return () => window.removeEventListener('keydown', onKeyDown)
|
|
}, [])
|
|
|
|
/** Keep local draft in sync if the param changes externally (e.g. nav, browser history). */
|
|
useEffect(() => {
|
|
const external = searchParams.get('search') ?? ''
|
|
setDraft((prev) => {
|
|
if (prev !== external) {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current)
|
|
debounceRef.current = null
|
|
}
|
|
return external
|
|
}
|
|
return prev
|
|
})
|
|
}, [searchParams])
|
|
|
|
return (
|
|
<div className="search">
|
|
<Search size={14} aria-hidden="true"/>
|
|
<input
|
|
ref={inputRef}
|
|
value={draft}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Search services, features, docs, and more"
|
|
aria-label="Search services, features, docs, and more"
|
|
/>
|
|
<span className="kbd" aria-hidden="true">/</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
|
|
const match = pathname.match(/^\/(?:cloud-explorer|console|diagnostics)\/(aws|azure|gcp)(?:\/|$)/)
|
|
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
|
|
}
|