diff --git a/web/src/assets/hero.png b/web/src/assets/hero.png deleted file mode 100644 index 02251f4..0000000 Binary files a/web/src/assets/hero.png and /dev/null differ diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/web/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web/src/assets/vite.svg b/web/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/web/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 8ba332c..2f9b632 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -40,9 +40,9 @@ const NAV: NavItem[] = [ { to: '/blocklists', label: 'Blocklists', icon: ListChecks }, { to: '/domains', label: 'Domains', icon: Globe }, { to: '/lookup', label: 'Lookup', icon: Search }, - { to: '/overrides', label: 'Address overrides', icon: MapPin }, - { to: '/local-zones', label: 'Local zones', icon: FileCode2 }, - { to: '/zones', label: 'Zones', icon: Layers }, + { to: '/overrides', label: 'Address redirects', icon: MapPin }, + { to: '/local-zones', label: 'Zone files', icon: FileCode2 }, + { to: '/zones', label: 'Managed zones', icon: Layers }, { to: '/clients', label: 'Clients', icon: Users }, { to: '/upstreams', label: 'Upstreams', icon: Network }, { to: '/settings', label: 'Settings', icon: Settings }, @@ -50,7 +50,7 @@ const NAV: NavItem[] = [ const MOBILE_QUERY = '(max-width: 1023px)' -export function AppShell({ +function Shell({ children, health, nodeId, @@ -106,15 +106,14 @@ export function AppShell({ }) .finally(() => { setSigningOut(false) - navigate('/auth/login', { replace: true }) + navigate('/auth/login?signed_out=1', { replace: true }) }) }, [navigate]) const rail = !isMobile && collapsed return ( - -
+
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries the hamburger that opens the sheet instead. */}
@@ -285,10 +284,17 @@ export function AppShell({ {children}
-
-
+ ) } /** Re-exported so pages can label collapsed rail items without importing ui. */ export { Tooltip } + +export function AppShell(props: Parameters[0]) { + return ( + + + + ) +} diff --git a/web/src/components/ui/Kbd.tsx b/web/src/components/ui/Kbd.tsx deleted file mode 100644 index 86407bb..0000000 --- a/web/src/components/ui/Kbd.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ReactNode } from 'react' -import { cn } from '../../lib/cn.ts' - -export function Kbd({ children, className }: { children: ReactNode; className?: string }) { - return ( - - {children} - - ) -} diff --git a/web/src/components/ui/SectionTitle.tsx b/web/src/components/ui/SectionTitle.tsx deleted file mode 100644 index d3053d4..0000000 --- a/web/src/components/ui/SectionTitle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { ReactNode } from 'react' -import { cn } from '../../lib/cn.ts' - -export function SectionTitle({ - title, - description, - actions, - className, -}: { - title: ReactNode - description?: ReactNode - actions?: ReactNode - className?: string -}) { - return ( -
-
-

{title}

- {description !== undefined && ( -

{description}

- )} -
- {actions !== undefined && ( -
{actions}
- )} -
- ) -} diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts index 318057b..e825b23 100644 --- a/web/src/components/ui/index.ts +++ b/web/src/components/ui/index.ts @@ -19,9 +19,7 @@ export { Tabs } from './Tabs.tsx' export { Spinner } from './Spinner.tsx' export { Progress } from './Progress.tsx' export { Pagination } from './Pagination.tsx' -export { Kbd } from './Kbd.tsx' export { CopyButton } from './CopyButton.tsx' -export { SectionTitle } from './SectionTitle.tsx' export type { ButtonProps, ButtonSize, ButtonVariant } from './Button.tsx' export type { InputProps } from './Input.tsx' diff --git a/web/src/pages/Domains.tsx b/web/src/pages/Domains.tsx index 4c689b3..b9aeeee 100644 --- a/web/src/pages/Domains.tsx +++ b/web/src/pages/Domains.tsx @@ -53,9 +53,6 @@ const PER_PAGE = 50 const ACTIONS = [ { value: 'block', label: 'Block (NXDOMAIN)' }, { value: 'allow', label: 'Allow (pass through)' }, - { value: 'drop', label: 'Drop (no response)' }, - { value: 'nxdomain', label: 'NXDOMAIN' }, - { value: 'redirect', label: 'Redirect' }, ] const NAME_HINT = @@ -226,9 +223,15 @@ export default function Domains() { delete: ( setPendingDelete(item)} /> ), @@ -238,7 +241,7 @@ export default function Domains() {
New zone} /> diff --git a/web/src/pages/Overrides.tsx b/web/src/pages/Overrides.tsx index ad706c8..c943a2c 100644 --- a/web/src/pages/Overrides.tsx +++ b/web/src/pages/Overrides.tsx @@ -1,23 +1,185 @@ import { useState } from 'react' -import { Pencil, Plus, Trash2 } from 'lucide-react' +import type { FormEvent } from 'react' +import { ArrowRight, Pencil, Plus, Trash2, X } from 'lucide-react' + import { deleteAddressOverride, getAddressOverrides, putAddressOverride } from '../lib/api' import { useApi } from '../hooks/useApi' import { useWhoami } from '../lib/session.ts' -import { Button, Card, EmptyState, IconButton, Input, Table, useToast } from '../components/ui' -import { PageHeader } from '../components/layout' +import { Badge, Button, Card, EmptyState, IconButton, Input, Table, useToast } from '../components/ui' +import { LoadingBlock, PageError, PageHeader } from '../components/layout' export default function Overrides() { - const {canWrite}=useWhoami(); const {toast}=useToast(); const {data,error,loading,refetch}=useApi(()=>getAddressOverrides(),[]) - const [domain,setDomain]=useState(''); const [address,setAddress]=useState('') - const save=async()=>{try{await putAddressOverride(domain,address);setDomain('');setAddress('');refetch();toast({title:'Address override saved',tone:'ok'})}catch(e){toast({title:e instanceof Error?e.message:'Could not save override',tone:'danger'})}} - const remove=async(d:string)=>{try{await deleteAddressOverride(d);refetch();toast({title:`Removed ${d}`,tone:'ok'})}catch(e){toast({title:e instanceof Error?e.message:'Could not remove override',tone:'danger'})}} - const items=data?.items??[] - return
- -
setDomain(e.target.value)} disabled={!canWrite||!data?.writable}/>setAddress(e.target.value)} disabled={!canWrite||!data?.writable}/>
-
- - {error?

{error}

:loading?

Loading…

:items.length===0?:({__key:i.domain,domain:{i.domain},address:{i.address},actions:{setDomain(i.domain);setAddress(i.address)}} disabled={!canWrite||!data?.writable}/>void remove(i.domain)} disabled={!canWrite||!data?.writable}/>}))} rowKey={(row)=>String(row.__key)}/>} - - + const { canWrite } = useWhoami() + const { toast } = useToast() + const { data, error, loading, refetch } = useApi(getAddressOverrides, []) + const [domain, setDomain] = useState('') + const [address, setAddress] = useState('') + const [editing, setEditing] = useState(false) + const [formError, setFormError] = useState(null) + const [saving, setSaving] = useState(false) + const [removing, setRemoving] = useState(null) + + const items = data?.items ?? [] + const writable = canWrite && data?.writable === true + + const reset = (): void => { + setDomain('') + setAddress('') + setEditing(false) + setFormError(null) + } + + const submit = async (event: FormEvent): Promise => { + event.preventDefault() + setFormError(null) + const normalized = domain.trim().toLowerCase().replace(/\.$/, '') + if (!normalized || !address.trim()) { + setFormError('Enter both a domain and an IP address.') + return + } + setSaving(true) + try { + await putAddressOverride(normalized, address.trim()) + reset() + await refetch() + toast({ title: 'Address redirect saved', tone: 'ok' }) + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Could not save this redirect.') + } finally { + setSaving(false) + } + } + + const onDelete = async (name: string): Promise => { + if (!window.confirm(`Remove the redirect for ${name}?`)) return + setRemoving(name) + try { + await deleteAddressOverride(name) + if (domain === name) reset() + await refetch() + toast({ title: `Removed ${name}`, tone: 'ok' }) + } catch (err) { + toast({ title: err instanceof Error ? err.message : 'Could not remove the redirect', tone: 'danger' }) + } finally { + setRemoving(null) + } + } + + if (loading) return + if (error !== null) return void refetch()} /> + + return ( +
+ + +
+ + {!writable && ( +
+ {!canWrite + ? 'This account is read-only.' + : 'The shared Kubernetes storage is unavailable, so redirects can only be viewed.'} +
+ )} +
void submit(event)}> + setDomain(event.currentTarget.value)} + disabled={!writable || saving || editing} + autoComplete="off" + spellCheck={false} + /> + setAddress(event.currentTarget.value)} + disabled={!writable || saving} + autoComplete="off" + spellCheck={false} + /> +

+ This creates an A or AAAA response based on the address you enter. It applies to the domain and its subdomains. +

+ {formError !== null &&

{formError}

} +
+ + {editing && ( + + )} +
+ +
+ + + {items.length === 0 ? ( + + ) : ( +
({ + __key: item.domain, + domain: ( +
+ {item.domain} + and subdomains +
+ ), + address: {item.address}, + actions: ( + + { + setDomain(item.domain) + setAddress(item.address) + setEditing(true) + setFormError(null) + }} + disabled={!writable || saving || removing !== null} + /> + void onDelete(item.domain)} + disabled={!writable || removing !== null || saving} + /> + + ), + }))} + rowKey={(row) => String(row.__key)} + /> + )} + + + +
+ These redirects also appear in Domains as compiled rules. Edit or remove them here; the Domains list is for inspection and inline allow/block rules. +
+ + ) } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 9caa967..8fcf318 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -317,7 +317,7 @@ export default function Settings() { > Validate - + } } > diff --git a/web/src/pages/Upstreams.tsx b/web/src/pages/Upstreams.tsx index 119a8b0..abfcac9 100644 --- a/web/src/pages/Upstreams.tsx +++ b/web/src/pages/Upstreams.tsx @@ -426,7 +426,7 @@ export default function Upstreams() { ) : (
@@ -456,7 +456,7 @@ export default function Upstreams() { label="Name" value={b.name} onChange={(e) => setBootstrap(i, { name: e.target.value })} - placeholder="root servers" + placeholder="dns.example.net" />
- setBootstrap(i, { refresh: e.target.value })} - placeholder="24h" - />