web: commit the in-progress UI polish as found in the working tree

Committed unchanged, by someone other than its author, so that the cluster UI
work that follows has a base to build on and can be reviewed separately: the
rewritten Overrides page (forms, validation, row-level state instead of
one-line handlers), the form and table clean-ups on Domains, Zones, Upstreams,
Settings and LocalZones, the AppShell fix that moves the shell inside the
identity provider, new nav labels, and the removal of the unused Kbd and
SectionTitle components and the Vite starter assets.

Login.tsx is deliberately not part of this: it belongs with the server-side OIDC
login work, which is still uncommitted, and stays in the working tree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:48:19 +02:00
1 parent 03d771430b
commit 7d58b80583
13 files changed
+207 -89

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

Before

Width:  |  Height:  |  Size: 8.5 KiB

+13 -7
View File
@@ -40,9 +40,9 @@ const NAV: NavItem[] = [
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks }, { to: '/blocklists', label: 'Blocklists', icon: ListChecks },
{ to: '/domains', label: 'Domains', icon: Globe }, { to: '/domains', label: 'Domains', icon: Globe },
{ to: '/lookup', label: 'Lookup', icon: Search }, { to: '/lookup', label: 'Lookup', icon: Search },
{ to: '/overrides', label: 'Address overrides', icon: MapPin }, { to: '/overrides', label: 'Address redirects', icon: MapPin },
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 }, { to: '/local-zones', label: 'Zone files', icon: FileCode2 },
{ to: '/zones', label: 'Zones', icon: Layers }, { to: '/zones', label: 'Managed zones', icon: Layers },
{ to: '/clients', label: 'Clients', icon: Users }, { to: '/clients', label: 'Clients', icon: Users },
{ to: '/upstreams', label: 'Upstreams', icon: Network }, { to: '/upstreams', label: 'Upstreams', icon: Network },
{ to: '/settings', label: 'Settings', icon: Settings }, { to: '/settings', label: 'Settings', icon: Settings },
@@ -50,7 +50,7 @@ const NAV: NavItem[] = [
const MOBILE_QUERY = '(max-width: 1023px)' const MOBILE_QUERY = '(max-width: 1023px)'
export function AppShell({ function Shell({
children, children,
health, health,
nodeId, nodeId,
@@ -106,14 +106,13 @@ export function AppShell({
}) })
.finally(() => { .finally(() => {
setSigningOut(false) setSigningOut(false)
navigate('/auth/login', { replace: true }) navigate('/auth/login?signed_out=1', { replace: true })
}) })
}, [navigate]) }, [navigate])
const rail = !isMobile && collapsed const rail = !isMobile && collapsed
return ( return (
<WhoamiProvider>
<div className="min-h-svh bg-canvas"> <div className="min-h-svh bg-canvas">
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries {/* Desktop rail toggle lives in the top bar; on mobile the bar carries
the hamburger that opens the sheet instead. */} the hamburger that opens the sheet instead. */}
@@ -286,9 +285,16 @@ export function AppShell({
</div> </div>
</main> </main>
</div> </div>
</WhoamiProvider>
) )
} }
/** Re-exported so pages can label collapsed rail items without importing ui. */ /** Re-exported so pages can label collapsed rail items without importing ui. */
export { Tooltip } export { Tooltip }
export function AppShell(props: Parameters<typeof Shell>[0]) {
return (
<WhoamiProvider>
<Shell {...props} />
</WhoamiProvider>
)
}
-15
View File
@@ -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 (
<kbd
className={cn(
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-line-strong bg-surface-3 px-1.5 font-mono text-[10px] font-medium text-fg-muted',
className,
)}
>
{children}
</kbd>
)
}
-28
View File
@@ -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 (
<div className={cn('flex flex-wrap items-end justify-between gap-3', className)}>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-fg">{title}</h2>
{description !== undefined && (
<p className="mt-0.5 text-xs text-fg-muted">{description}</p>
)}
</div>
{actions !== undefined && (
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
)}
</div>
)
}
-2
View File
@@ -19,9 +19,7 @@ export { Tabs } from './Tabs.tsx'
export { Spinner } from './Spinner.tsx' export { Spinner } from './Spinner.tsx'
export { Progress } from './Progress.tsx' export { Progress } from './Progress.tsx'
export { Pagination } from './Pagination.tsx' export { Pagination } from './Pagination.tsx'
export { Kbd } from './Kbd.tsx'
export { CopyButton } from './CopyButton.tsx' export { CopyButton } from './CopyButton.tsx'
export { SectionTitle } from './SectionTitle.tsx'
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button.tsx' export type { ButtonProps, ButtonSize, ButtonVariant } from './Button.tsx'
export type { InputProps } from './Input.tsx' export type { InputProps } from './Input.tsx'
+9 -6
View File
@@ -53,9 +53,6 @@ const PER_PAGE = 50
const ACTIONS = [ const ACTIONS = [
{ value: 'block', label: 'Block (NXDOMAIN)' }, { value: 'block', label: 'Block (NXDOMAIN)' },
{ value: 'allow', label: 'Allow (pass through)' }, { value: 'allow', label: 'Allow (pass through)' },
{ value: 'drop', label: 'Drop (no response)' },
{ value: 'nxdomain', label: 'NXDOMAIN' },
{ value: 'redirect', label: 'Redirect' },
] ]
const NAME_HINT = const NAME_HINT =
@@ -226,9 +223,15 @@ export default function Domains() {
delete: ( delete: (
<IconButton <IconButton
icon={Trash2} icon={Trash2}
label={`delete ${item.name}`}
variant="ghost" variant="ghost"
disabled={!canWrite} disabled={!canWrite || !item.source.startsWith('inline:')}
label={
item.source.startsWith('inline:')
? `delete ${item.name}`
: item.source === 'managed:address-overrides'
? 'Edit this redirect under Address redirects'
: `Managed by ${item.source}; edit at its source`
}
onClick={() => setPendingDelete(item)} onClick={() => setPendingDelete(item)}
/> />
), ),
@@ -238,7 +241,7 @@ export default function Domains() {
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
title="Domains" title="Domains"
description="Inline allow and block rules, checked before the downloaded lists." description="Add inline allow and block rules. Address redirects are managed separately under Address redirects; downloaded and managed rules are shown here for inspection."
actions={ actions={
<Button <Button
icon={Plus} icon={Plus}
+3 -3
View File
@@ -1,4 +1,4 @@
/** Edit the authoritative zone files shared by the DNS replicas. */ /** Edit console-managed authoritative zone files shared by DNS replicas. */
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { FileCode2, Plus, Save, Trash2 } from 'lucide-react' import { FileCode2, Plus, Save, Trash2 } from 'lucide-react'
@@ -123,8 +123,8 @@ export default function LocalZones() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
title="Local zones" title="Zone files"
description="Add authoritative DNS records shared across replicas. Missing names under a zone apex return NXDOMAIN; for a single-host override, use that hostname as the apex." description="Edit master-file data for authoritative zones. These files are shared across replicas; names missing beneath a zone apex return NXDOMAIN. Use Managed zones for record-by-record editing."
actions={<Button icon={Plus} onClick={startNew} disabled={!canWrite}>New zone</Button>} actions={<Button icon={Plus} onClick={startNew} disabled={!canWrite}>New zone</Button>}
/> />
+175 -13
View File
@@ -1,23 +1,185 @@
import { useState } from 'react' 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 { deleteAddressOverride, getAddressOverrides, putAddressOverride } from '../lib/api'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { useWhoami } from '../lib/session.ts' import { useWhoami } from '../lib/session.ts'
import { Button, Card, EmptyState, IconButton, Input, Table, useToast } from '../components/ui' import { Badge, Button, Card, EmptyState, IconButton, Input, Table, useToast } from '../components/ui'
import { PageHeader } from '../components/layout' import { LoadingBlock, PageError, PageHeader } from '../components/layout'
export default function Overrides() { export default function Overrides() {
const {canWrite}=useWhoami(); const {toast}=useToast(); const {data,error,loading,refetch}=useApi(()=>getAddressOverrides(),[]) const { canWrite } = useWhoami()
const [domain,setDomain]=useState(''); const [address,setAddress]=useState('') const { toast } = useToast()
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 { data, error, loading, refetch } = useApi(getAddressOverrides, [])
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 [domain, setDomain] = useState('')
const items=data?.items??[] const [address, setAddress] = useState('')
return <div className="space-y-4"><PageHeader title="Address overrides" description="Map a domain and all its subdomains to an IP address. These rules answer through DNS policy without creating an authoritative zone." /> const [editing, setEditing] = useState(false)
<Card title="Add or edit an override" subtitle={data?.writable?'Changes are shared across replicas.':'The shared Kubernetes ConfigMap is unavailable; overrides are read-only.'}> const [formError, setFormError] = useState<string | null>(null)
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] items-end"><Input label="Domain" placeholder="example.com" value={domain} onChange={e=>setDomain(e.target.value)} disabled={!canWrite||!data?.writable}/><Input label="Address" placeholder="10.10.3.10 or 2001:db8::1" value={address} onChange={e=>setAddress(e.target.value)} disabled={!canWrite||!data?.writable}/><Button icon={Plus} onClick={()=>void save()} disabled={!canWrite||!data?.writable||!domain.trim()||!address.trim()}>{items.some(i=>i.domain===domain.trim().toLowerCase())?'Save':'Add override'}</Button></div> const [saving, setSaving] = useState(false)
const [removing, setRemoving] = useState<string | null>(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<HTMLFormElement>): Promise<void> => {
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<void> => {
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 <LoadingBlock />
if (error !== null) return <PageError error={error} onRetry={() => void refetch()} />
return (
<div className="space-y-4">
<PageHeader
title="Address redirects"
description="Answer a domain and all its subdomains with one IP address. These rules use the shared managed RPZ source and do not create an authoritative zone."
/>
<div className="grid gap-4 xl:grid-cols-[minmax(280px,0.8fr)_minmax(0,1.5fr)]">
<Card
title={editing ? 'Edit redirect' : 'Add a redirect'}
subtitle="Use a zone when you need different records for names under the domain."
>
{!writable && (
<div className="mb-4 rounded-lg border border-line bg-surface-2 p-3 text-sm text-fg-muted">
{!canWrite
? 'This account is read-only.'
: 'The shared Kubernetes storage is unavailable, so redirects can only be viewed.'}
</div>
)}
<form className="space-y-3" onSubmit={(event) => void submit(event)}>
<Input
label="Domain"
placeholder="service.example.net"
value={domain}
onChange={(event) => setDomain(event.currentTarget.value)}
disabled={!writable || saving || editing}
autoComplete="off"
spellCheck={false}
/>
<Input
label="Destination IP"
placeholder="192.0.2.24 or 2001:db8::24"
value={address}
onChange={(event) => setAddress(event.currentTarget.value)}
disabled={!writable || saving}
autoComplete="off"
spellCheck={false}
/>
<p className="text-xs leading-5 text-fg-subtle">
This creates an A or AAAA response based on the address you enter. It applies to the domain and its subdomains.
</p>
{formError !== null && <p role="alert" className="text-sm text-danger">{formError}</p>}
<div className="flex flex-wrap gap-2">
<Button type="submit" icon={editing ? Pencil : Plus} loading={saving} disabled={!writable || !domain.trim() || !address.trim()}>
{editing ? 'Save redirect' : 'Add redirect'}
</Button>
{editing && (
<Button type="button" variant="ghost" icon={X} onClick={reset} disabled={saving}>
Cancel edit
</Button>
)}
</div>
</form>
</Card> </Card>
<Card title="Overrides" subtitle={`${items.length} domain${items.length===1?'':'s'}`}>
{error?<p className="text-sm text-red-600">{error}</p>:loading?<p className="text-sm text-slate-500">Loading…</p>:items.length===0?<EmptyState title="No address overrides" description="Add a domain and destination IP above."/>:<Table columns={[{key:'domain',header:'Domain'},{key:'address',header:'Address'},{key:'actions',header:'',align:'right'}]} rows={items.map(i=>({__key:i.domain,domain:<span className="font-mono">{i.domain}</span>,address:<span className="font-mono">{i.address}</span>,actions:<span className="flex justify-end gap-1"><IconButton icon={Pencil} label={`Edit ${i.domain}`} onClick={()=>{setDomain(i.domain);setAddress(i.address)}} disabled={!canWrite||!data?.writable}/><IconButton icon={Trash2} label={`Delete ${i.domain}`} onClick={()=>void remove(i.domain)} disabled={!canWrite||!data?.writable}/></span>}))} rowKey={(row)=>String(row.__key)}/>} <Card
title="Current redirects"
subtitle={`${items.length} ${items.length === 1 ? 'domain' : 'domains'} · source: managed:address-overrides`}
>
{items.length === 0 ? (
<EmptyState
icon={ArrowRight}
title="No address redirects"
description="Add a domain and destination IP to create the first managed redirect."
/>
) : (
<Table
columns={[
{ key: 'domain', header: 'Domain' },
{ key: 'address', header: 'Destination IP' },
{ key: 'actions', header: '', align: 'right' },
]}
rows={items.map((item) => ({
__key: item.domain,
domain: (
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{item.domain}</span>
<Badge tone="info">and subdomains</Badge>
</div>
),
address: <span className="font-mono text-sm tabular">{item.address}</span>,
actions: (
<span className="flex justify-end gap-1">
<IconButton
icon={Pencil}
label={`Edit ${item.domain}`}
onClick={() => {
setDomain(item.domain)
setAddress(item.address)
setEditing(true)
setFormError(null)
}}
disabled={!writable || saving || removing !== null}
/>
<IconButton
icon={Trash2}
label={`Remove ${item.domain}`}
onClick={() => void onDelete(item.domain)}
disabled={!writable || removing !== null || saving}
/>
</span>
),
}))}
rowKey={(row) => String(row.__key)}
/>
)}
</Card> </Card>
</div> </div>
<div className="rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm text-fg-muted">
These redirects also appear in <strong className="font-medium text-fg">Domains</strong> as compiled rules. Edit or remove them here; the Domains list is for inspection and inline allow/block rules.
</div>
</div>
)
} }
+2 -2
View File
@@ -317,7 +317,7 @@ export default function Settings() {
> >
Validate Validate
</Button> </Button>
<Button {writable && <Button
variant="primary" variant="primary"
icon={Save} icon={Save}
onClick={() => void onSave()} onClick={() => void onSave()}
@@ -326,7 +326,7 @@ export default function Settings() {
title={canWrite ? undefined : 'This API key is read-only'} title={canWrite ? undefined : 'This API key is read-only'}
> >
Save Save
</Button> </Button>}
</div> </div>
} }
> >
+3 -9
View File
@@ -426,7 +426,7 @@ export default function Upstreams() {
<Card <Card
title="Bootstraps" title="Bootstraps"
subtitle="Name servers used to resolve the upstream hostnames themselves" subtitle="Static IP addresses for named upstreams"
actions={ actions={
<Button <Button
size="sm" size="sm"
@@ -446,7 +446,7 @@ export default function Upstreams() {
<EmptyState <EmptyState
icon={Gauge} icon={Gauge}
title="No bootstrap resolvers" title="No bootstrap resolvers"
description="Without them, an upstream address must already be an IP." description="Named upstreams use the system resolver unless an address is listed here."
/> />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -456,7 +456,7 @@ export default function Upstreams() {
label="Name" label="Name"
value={b.name} value={b.name}
onChange={(e) => setBootstrap(i, { name: e.target.value })} onChange={(e) => setBootstrap(i, { name: e.target.value })}
placeholder="root servers" placeholder="dns.example.net"
/> />
<Input <Input
label="Addresses" label="Addresses"
@@ -466,12 +466,6 @@ export default function Upstreams() {
hint="Comma separated." hint="Comma separated."
/> />
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<Input
label="Refresh"
value={b.refresh}
onChange={(e) => setBootstrap(i, { refresh: e.target.value })}
placeholder="24h"
/>
<IconButton <IconButton
icon={Trash2} icon={Trash2}
label={`remove bootstrap ${b.name}`} label={`remove bootstrap ${b.name}`}
+2 -2
View File
@@ -418,8 +418,8 @@ export default function Zones() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
title="Zones" title="Managed zones"
description="Local zones this resolver answers for itself, and the names it delegates elsewhere." description="Manage records and delegations for console-owned authoritative zones. Use Zone files to edit master-file data."
actions={ actions={
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button