build / build-push (push) Successful in 29s
Local zones were only editable as mounted files, so operators could not manage DNS entries from the console. Add a Local zones editor backed by a shared Kubernetes ConfigMap, validate master files before saving, scope the pod service account to that ConfigMap, and reload changed zones on every replica while flushing stale cache entries.
293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
import {
|
|
Database,
|
|
FileCode2,
|
|
Globe,
|
|
LayoutDashboard,
|
|
ListChecks,
|
|
Lock,
|
|
LogOut,
|
|
Menu,
|
|
Network,
|
|
PanelLeftClose,
|
|
ScrollText,
|
|
Settings,
|
|
Users,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { cn } from '../../lib/cn.ts'
|
|
import { logout } from '../../lib/api.ts'
|
|
import { useWhoami } from '../../lib/session.ts'
|
|
import type { Health } from '../../lib/types.ts'
|
|
import { HealthPill } from './HealthPill.tsx'
|
|
import { ThemeToggle } from './ThemeToggle.tsx'
|
|
import { WhoamiProvider } from './WhoamiProvider.tsx'
|
|
import { Tooltip } from '../ui/Tooltip.tsx'
|
|
|
|
interface NavItem {
|
|
to: string
|
|
label: string
|
|
icon: LucideIcon
|
|
end?: boolean
|
|
}
|
|
|
|
const NAV: NavItem[] = [
|
|
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
|
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
|
|
{ to: '/domains', label: 'Domains', icon: Globe },
|
|
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
|
|
{ to: '/clients', label: 'Clients', icon: Users },
|
|
{ to: '/upstreams', label: 'Upstreams', icon: Network },
|
|
{ to: '/logs', label: 'Logs', icon: ScrollText },
|
|
{ to: '/cache', label: 'Cache', icon: Database },
|
|
{ to: '/settings', label: 'Settings', icon: Settings },
|
|
]
|
|
|
|
const MOBILE_QUERY = '(max-width: 1023px)'
|
|
|
|
export function AppShell({
|
|
children,
|
|
health,
|
|
nodeId,
|
|
}: {
|
|
children: ReactNode
|
|
health?: Health
|
|
nodeId?: string
|
|
}) {
|
|
const [collapsed, setCollapsed] = useState(false)
|
|
const [sheetOpen, setSheetOpen] = useState(false)
|
|
const [isMobile, setIsMobile] = useState(false)
|
|
const [signingOut, setSigningOut] = useState(false)
|
|
const location = useLocation()
|
|
const navigate = useNavigate()
|
|
// The provider is the app's own, so the shell's header can read it too.
|
|
const { readOnly, identity } = useWhoami()
|
|
|
|
useEffect(() => {
|
|
const query = window.matchMedia(MOBILE_QUERY)
|
|
const sync = (): void => {
|
|
setIsMobile(query.matches)
|
|
if (!query.matches) setSheetOpen(false)
|
|
}
|
|
sync()
|
|
query.addEventListener('change', sync)
|
|
return () => query.removeEventListener('change', sync)
|
|
}, [])
|
|
|
|
// Any navigation closes the mobile sheet.
|
|
useEffect(() => {
|
|
setSheetOpen(false)
|
|
}, [location.pathname])
|
|
|
|
useEffect(() => {
|
|
if (!sheetOpen) return
|
|
const onKey = (event: KeyboardEvent): void => {
|
|
if (event.key === 'Escape') setSheetOpen(false)
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
const previous = document.body.style.overflow
|
|
document.body.style.overflow = 'hidden'
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey)
|
|
document.body.style.overflow = previous
|
|
}
|
|
}, [sheetOpen])
|
|
|
|
const onSignOut = useCallback(() => {
|
|
setSigningOut(true)
|
|
logout()
|
|
.catch(() => {
|
|
/* the token is cleared either way */
|
|
})
|
|
.finally(() => {
|
|
setSigningOut(false)
|
|
navigate('/auth/login', { replace: true })
|
|
})
|
|
}, [navigate])
|
|
|
|
const rail = !isMobile && collapsed
|
|
|
|
return (
|
|
<WhoamiProvider>
|
|
<div className="min-h-svh bg-canvas">
|
|
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries
|
|
the hamburger that opens the sheet instead. */}
|
|
<header className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface-2 px-3">
|
|
{isMobile ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Open navigation"
|
|
onClick={() => setSheetOpen(true)}
|
|
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<Menu size={18} strokeWidth={2} aria-hidden="true" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
onClick={() => setCollapsed((value) => !value)}
|
|
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<PanelLeftClose
|
|
size={18}
|
|
strokeWidth={2}
|
|
aria-hidden="true"
|
|
className={cn('transition-transform', collapsed && 'rotate-180')}
|
|
/>
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-accent-soft text-accent">
|
|
<Globe size={15} strokeWidth={2.25} aria-hidden="true" />
|
|
</span>
|
|
<span className="truncate text-sm font-semibold tracking-tight text-fg">fancy-dns</span>
|
|
</div>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{nodeId !== undefined && nodeId !== '' && (
|
|
<span
|
|
title={`Node ${nodeId}`}
|
|
className="tabular hidden max-w-40 truncate rounded-md border border-line bg-surface px-2 py-1 font-mono text-xs text-fg-muted sm:inline-block"
|
|
>
|
|
{nodeId}
|
|
</span>
|
|
)}
|
|
{health !== undefined && <HealthPill health={health} />}
|
|
<ThemeToggle />
|
|
<button
|
|
type="button"
|
|
aria-label="Sign out"
|
|
title="Sign out"
|
|
disabled={signingOut}
|
|
onClick={onSignOut}
|
|
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg disabled:opacity-50"
|
|
>
|
|
<LogOut size={16} strokeWidth={2} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Desktop sidebar. */}
|
|
<aside
|
|
className={cn(
|
|
'fixed top-14 bottom-0 left-0 z-30 hidden shrink-0 border-r border-line bg-surface transition-[width] duration-200 lg:flex lg:flex-col',
|
|
rail ? 'w-16' : 'w-56',
|
|
)}
|
|
>
|
|
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
|
{NAV.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
end={item.end}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
|
|
isActive
|
|
? 'bg-accent-soft text-accent'
|
|
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
|
|
rail && 'justify-center px-0',
|
|
)
|
|
}
|
|
title={rail ? item.label : undefined}
|
|
>
|
|
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
|
|
{!rail && <span className="truncate">{item.label}</span>}
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
</aside>
|
|
|
|
{/* Mobile sheet. */}
|
|
{sheetOpen && (
|
|
<div className="fixed inset-0 z-50 lg:hidden">
|
|
<button
|
|
type="button"
|
|
aria-label="Close navigation"
|
|
onClick={() => setSheetOpen(false)}
|
|
className="absolute inset-0 bg-black/60"
|
|
/>
|
|
<aside
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Navigation"
|
|
className="absolute inset-y-0 left-0 flex w-64 flex-col border-r border-line bg-surface shadow-panel"
|
|
>
|
|
<div className="flex h-14 items-center justify-between border-b border-line px-3">
|
|
<span className="text-sm font-semibold text-fg">fancy-dns</span>
|
|
<button
|
|
type="button"
|
|
aria-label="Close navigation"
|
|
onClick={() => setSheetOpen(false)}
|
|
className="inline-flex size-8 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<X size={16} strokeWidth={2} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
|
{NAV.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
end={item.end}
|
|
onClick={() => setSheetOpen(false)}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
|
|
isActive
|
|
? 'bg-accent-soft text-accent'
|
|
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
|
|
)
|
|
}
|
|
>
|
|
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
|
|
<span className="truncate">{item.label}</span>
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
|
|
<main
|
|
className={cn(
|
|
'pt-14 transition-[padding] duration-200',
|
|
isMobile ? 'px-3 py-4' : rail ? 'pl-16' : 'pl-56',
|
|
)}
|
|
>
|
|
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-4 p-3 sm:p-4 lg:p-6">
|
|
{readOnly && (
|
|
<p
|
|
role="status"
|
|
className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn-soft px-3 py-2 text-xs text-fg"
|
|
>
|
|
<Lock size={14} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
|
|
<span>
|
|
Read-only session
|
|
{identity !== '' && (
|
|
<>
|
|
{' '}
|
|
as <span className="font-mono">{identity}</span>
|
|
</>
|
|
)}
|
|
: this credential may read the node but not change it, so every write control
|
|
below is disabled. The daemon answers 403 on the write routes for this key.
|
|
</span>
|
|
</p>
|
|
)}
|
|
{children}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</WhoamiProvider>
|
|
)
|
|
}
|
|
|
|
/** Re-exported so pages can label collapsed rail items without importing ui. */
|
|
export { Tooltip }
|