Files
floci-ui/packages/frontend/src/components/Layout.tsx
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
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.
2026-10-24 14:30:44 -04:00

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