import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom' import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun} 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 ( {icon} {label} ) } /** * 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 if (isError) { return (
Cloud Services · {cloudLabel}
) } 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 (
Cloud Services · {cloudLabel} {filteredServices.length === 0 && search ? (
No matching services
) : ( groups.map(([group, services]) => (
{group} {services.map((service) => ( ))}
)) )}
) } function CloudServiceNavItem({cloud, service, collapsed}: {cloud: CloudProvider; service: CloudServiceDescriptor; collapsed: boolean}) { const reasonTitle = service.reason ? `${service.displayName} — ${service.reason}` : service.displayName const icon = if (service.availability === 'available') { const target = service.route.startsWith('/') ? service.route : `/cloud-explorer/${cloud}/${service.route}` return } // 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 (
{icon} {service.displayName} Soon
) } function CloudServiceNavSkeleton({cloudLabel}: {cloudLabel: string}) { return (
Cloud Services · {cloudLabel} {Array.from({length: SKELETON_ROWS}, (_, index) => ( ))}
) } /** Preserves the server's ordering while bucketing into its groups. */ function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudServiceDescriptor[]]> { const groups = new Map() 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, toggle} = useTheme() const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar() const isDark = theme === 'dark' 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 (
Skip to content
) } async function signOut(): Promise { 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(null) const debounceRef = useRef | 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) => { const value = event.target.value setDraft(value) if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => commit(value), 300) } const handleKeyDown = (event: React.KeyboardEvent) => { 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 (
) } function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' { const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/) return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp' }