Files
technitium-console/src/app/Shell.tsx
T
DenozordecandClaude Sonnet 5.5 87281a87d3
CI / check (push) Failing after 37s
CI / image (push) Skipped
Match the Technitium canvas: palette, shell, KPI strips, framed tables
The live UI did not follow the canvas because its base layer differed: dark
palette in hex (now the canvas oklch values), 13 px body text, flat Frame panels.

- Tokens: canvas palette, 14 px body, 1.4 line height; Frame without `dense`
  gets the 3 px ring; Panel takes a description line.
- Shell: 56 px brand button with version, 11 px group headings, user card at the
  foot, versions moved into the header status popover, Home / group / section
  crumbs, amber "Attention" status when blocking is off or paused.
- New KpiStrip and PageTabs; Dashboard KPIs in three frames and a Refresh button.
- Zones: KPI strip, tab with count, views, framed table with the canvas columns.
- Apps: Installed / App Store tabs and a grid instead of cards (store inline).
- Blocking: state banner and tab counts; one "Blocking" title.
- Settings: actions in the header, label-and-help rows with the control on the right.
- DHCP, Administration, DNS Client, Logs, Cache, About: subtitles, framed tables,
  KPI strips where the data exists; Login cover with the three-point list.
- docs/compose.shnt.example.yaml: production compose with the console image.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 12:47:27 +07:00

805 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import { visibleSections, type Permission } from './sections'
import { toTrail, writeRoute, readRoute, translateLegacyRoute, plainClick } from './route'
import { ChangePassword } from '../screens/modals/ChangePassword'
import { ChangeTheme } from '../screens/modals/ChangeTheme'
import { Configure2FA } from '../screens/modals/Configure2FA'
import { CreateApiToken } from '../screens/modals/CreateApiToken'
import { MyProfile } from '../screens/modals/MyProfile'
import { Dashboard } from '../screens/dashboard/Dashboard'
import { DnsClient } from '../screens/dnsclient/DnsClient'
import { About } from '../screens/about/About'
import { Apps } from '../screens/apps/Apps'
import { Cache } from '../screens/lists/Lists'
import { Blocking } from '../screens/blocking/Blocking'
import { Settings, type RecordSettings, type ServerInfo } from '../screens/settings/Settings'
import { Zones } from '../screens/zones/Zones'
import { Dhcp } from '../screens/dhcp/Dhcp'
import { Logs } from '../screens/logs/Logs'
import { Admin } from '../screens/admin/Admin'
import { SlotProvider } from './ChromeSlot'
import {
HandoffContext,
targetNode,
type DnsClientRequest,
type Handoff,
type QueryLogsRequest,
} from './handoff'
import styles from './Shell.module.css'
import { Icon, type IconName } from '../ui/Icon'
import { publicUrl } from './base'
import { Menu } from '../ui/Menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { ThisServerName } from '../ui/ClusterNodeSelect'
import { FooterLinks } from '../ui/FooterLinks'
import { Versions } from './Versions'
import { BlockingMark } from './BlockingMark'
import { useBlockingMark } from '../screens/blocking/blocking-state'
import { Confirm } from '../ui/Confirm'
import { Notifier } from '../ui/Notifier'
import type { Notice } from '../lib/notice'
import {
silenceUpdateNotification,
unsilenceUpdateNotification,
updateNotificationSilenced,
} from '../api/user'
type ModalId = 'profile' | 'password' | 'twofa' | 'token' | 'theme'
/** One glyph per section. No icon dependency: the server's CSP does not allow a
* CDN and an icon font would have to ship as a file in www/. */
/* The section icons live in `ui/Icon`, drawn; here we only say which one each
section carries. Blocking carries Blocked's: it is the section that took its place. */
/*
The sidebar's three groups. It is not a new taxonomy: it is upstream's own order,
with a gap where the kind of task already changed. What you operate daily, what you
configure and what you consult.
*/
const GROUPS: { name: string; ids: string[] }[] = [
{ name: 'DNS', ids: ['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'] },
{ name: 'Server', ids: ['settings', 'dhcp', 'admin', 'logs', 'about'] },
]
/* The sidebar's own preferences, per browser, as the canvas keeps them: the rail
and the pinned sections. A browser that refuses storage starts from the defaults. */
function stored<T>(key: string, fallback: T): T {
try {
const v = localStorage.getItem(key)
return v == null ? fallback : (JSON.parse(v) as T)
} catch {
return fallback
}
}
function keep(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* nothing to do */
}
}
const ICONS: Record<string, IconName> = {
dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked',
apps: 'apps', dnsclient: 'dnsclient', settings: 'settings',
dhcp: 'dhcp', admin: 'admin', logs: 'logs', about: 'about',
}
export interface ShellSession {
token: string
displayName: string
username: string
/** `Local`, `RemoteSSO` or `RemoteLDAP` since v15.5 (flat in `user/session/get`
* and `user/login`, WebServiceAuthApi.cs:74). */
type?: string
/** Obsolete since v15.5 (line 75): still sent, no longer read. */
isSsoUser?: boolean
/** Omitted for a `RemoteSSO` user (lines 77-82). */
totpEnabled?: boolean
info?: {
version: string
uptimestamp: string
dnsServerDomain: string
permissions?: Record<string, Permission>
clusterInitialized?: boolean
/** Only on an initialised cluster (WebServiceAuthApi.cs:104-106). */
clusterDomain?: string
clusterNodes?: { name: string; type: string }[]
/* The record defaults the zone forms show (WebServiceAuthApi.cs:96-100). */
defaultRecordTtl?: number
defaultNsRecordTtl?: number
defaultSoaRecordTtl?: number
useSoaSerialDateScheme?: boolean
dnssecValidation?: boolean
}
}
export function Shell({
session,
onLogout,
forcePasswordChange = false,
}: {
session: ShellSession
onLogout: () => void
/** A factory-credentials login: Change Password opens at once with `admin` filled in (auth.js:283-284). */
forcePasswordChange?: boolean
}) {
const permissions = session.info?.permissions
// Memoised: if it is recreated on every render, the `hashchange` resubscribes on each.
const sections = useMemo(() => visibleSections(permissions), [permissions])
/* The starting section comes from the address bar if it carries one, and only
if not, from the first visible one. See `app/route.ts` for the reasoning. */
/* Read once, on mount. `/allowed/` and `/blocked/` are Blocking's Rules tab now:
the bar is rewritten (`replaceState`) before the route is read, here in the
initialiser and not in the render body, so the side effect runs once; see
`app/route.ts`. */
const [initialRoute] = useState(() => {
translateLegacyRoute()
return readRoute(sections)
})
const [active, setActive] = useState(() => initialRoute?.section ?? sections[0]?.id ?? 'about')
const [drawer, setDrawer] = useState(false)
const [rail, setRail] = useState(() => stored<boolean>('shell.rail', false))
const [shut, setShut] = useState<string[]>([])
const [pins, setPins] = useState<string[]>(() => stored<string[]>('shell.pins', []))
const [palette, setPalette] = useState(false)
function toggleRail() {
setRail((v) => {
keep('shell.rail', !v)
return !v
})
}
function togglePin(id: string) {
setPins((v) => {
const next = v.includes(id) ? v.filter((x) => x !== id) : [...v, id]
keep('shell.pins', next)
return next
})
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPalette((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const [modal, setModal] = useState<ModalId | null>(forcePasswordChange ? 'password' : null)
/* The factory password travels only with the opening the login caused; from the
user menu `showChangePasswordModal()` is called with nothing (main.js). */
const [forcedCurrentPassword, setForcedCurrentPassword] = useState<string | undefined>(
forcePasswordChange ? 'admin' : undefined,
)
const [sub, setSub] = useState<string | null>(initialRoute?.sub ?? null)
const [displayName, setDisplayName] = useState(session.displayName)
const [totpEnabled, setTotpEnabled] = useState(session.totpEnabled ?? false)
/*
Silencing the update notice (`main.js:702-732`), which this console could read
and not write: `checkForUpdate` already refused to call the endpoint while the
preference was on, but nothing could ever turn it on, so the branch was
unreachable and the user had no way to stop being told. Restored on 2026-09-04,
checking About against upstream.
It is a per-browser preference and not a server setting, which is why it lives
in `localStorage` and why silencing it on your laptop does not silence it
anywhere else.
*/
const [silenced, setSilenced] = useState(() => updateNotificationSilenced())
const [askSilence, setAskSilence] = useState(false)
const [chromeNotice, setChromeNotice] = useState<Notice | null>(null)
/*
Once hidden in a session, the mark does not come back, and that is upstream's
behaviour, not an oversight of ours: `disableUpdateNotification` calls
`$("#lnkUpdateAvailable").hide()` and `enableUpdateNotification` never shows it
again. You get the notice back by logging in again. Replicated as it stands, the
same way `Quick Add`'s substring comparison is.
*/
const [markHidden, setMarkHidden] = useState(false)
function silence() {
setAskSilence(false)
silenceUpdateNotification()
setSilenced(true)
setMarkHidden(true)
setChromeNotice({
type: 'success',
title: 'Notification Disabled!',
text: 'Update notification was disabled successfully.',
})
}
/* Enabling does NOT re-check, exactly as upstream: it writes the preference and
says so. The next check is the next login. */
function unsilence() {
unsilenceUpdateNotification()
setSilenced(false)
setChromeNotice({
type: 'success',
title: 'Notification Enabled!',
text: 'Update notification was enabled successfully.',
})
}
function open(id: ModalId) {
setModal(id)
}
/*
`sessionData.info` is not fixed at login upstream: every load of this server's
settings, and every save or restore of them, rewrites its domain, version and
start time and redraws the tab title, the header and About
(`updateDnsSettingsDataAndGui`, main.js:1158-1166). Settings reports them here.
*/
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null)
/* The record defaults, from login until Settings loads them again
(main.js:1194-1196 and 1228). */
const [recordSettings, setRecordSettings] = useState<RecordSettings | null>(null)
const info = session.info && serverInfo ? { ...session.info, ...serverInfo } : session.info
// main.js: the document title carries the server domain and the version.
const title = info ? `${info.dnsServerDomain} - Technitium DNS Server v${info.version}` : null
useEffect(() => {
if (title != null) document.title = title
}, [title])
const current = sections.find((s) => s.id === active) ?? sections[0]
/* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */
const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'))
/*
The jumps other screens' row menus make (`app/handoff.ts`). The section switch
and the request land in the same render, so the target mounts already holding
it; the effect below then forgets it, because the target has taken what it
needs, and coming back later through the sidebar must find a clean screen.
A section the user cannot see is not opened: there would be no screen to land
on.
*/
const [pending, setPending] = useState<
{ dnsclient: DnsClientRequest } | { logs: QueryLogsRequest } | null
>(null)
useEffect(() => {
if (pending != null) setPending(null)
}, [pending])
const handoff = useMemo<Handoff>(() => {
const visible = (id: string) => sections.some((s) => s.id === id)
return {
queryDnsServer(domain, type, node) {
if (!visible('dnsclient')) return
setPending({ dnsclient: { domain, type: type ?? 'A', node: targetNode(node) } })
setActive('dnsclient')
setSub(null)
setDrawer(false)
},
showQueryLogs(domain, clientIp, node, filters) {
if (!visible('logs')) return
setPending({ logs: { domain, clientIp, node: targetNode(node), ...(filters != null ? { filters } : {}) } })
setActive('logs')
setSub('Query Logs')
setDrawer(false)
},
canShowQueryLogs: visible('logs'),
}
}, [sections])
/*
The effective sub. A section with sub-sections is ALWAYS in one of them, so
`null` means "the first one".
It is resolved here and not in each branch (there were five loose
`sub ?? 'General'`, and the sidebar repeated the same workaround to decide what
to mark) because a rule spread around is precisely the one that goes out of
sync: the menu said "General" and the address bar said `/settings/`, which is
half a page.
*/
const currentSub = current?.subs != null ? (sub ?? current.subs[0] ?? null) : null
/*
The URL follows the state, and the state follows the URL. The guard is in
`writeRoute`, which does not touch history if the route is already the current
one: without it, responding to `popstate` by setting the state would write the
route again.
*/
// The first write normalises the URL and must leave no history entry.
const firstRender = useRef(true)
const activeRef = useRef({ section: current?.id ?? 'about', sub })
useEffect(() => {
if (current == null) return
activeRef.current = { section: current.id, sub: currentSub }
writeRoute({ section: current.id, sub: currentSub }, firstRender.current)
firstRender.current = false
}, [current, sub, currentSub])
useEffect(() => {
function onChanged() {
translateLegacyRoute()
const r = readRoute(sections)
if (r == null) {
// A route that does not resolve left the bar and the screen saying different
// things. The screen rules: the URL is corrected.
writeRoute({ section: activeRef.current.section, sub: activeRef.current.sub }, true)
return
}
/*
A half address (`/settings/`, with no sub) is completed here and not in the
effect that writes the route: on going "back" the state does not change
(`sub` was already `null`), so that effect does not run again and the bar was
left saying half a page.
And it is completed by replacing: pushing would leave a new entry pointing at
where we already are, and the back button would go round in circles.
*/
const section = sections.find((x) => x.id === r.section)
const own = r.sub ?? section?.subs?.[0] ?? null
if (own !== r.sub) writeRoute({ section: r.section, sub: own }, true)
setActive((v) => (v === r.section ? v : r.section))
setSub((v) => (v === own ? v : own))
}
window.addEventListener('popstate', onChanged)
return () => window.removeEventListener('popstate', onChanged)
}, [sections])
const pinned = sections.filter((sec) => pins.includes(sec.id))
function go(id: string, to: string | null) {
setActive(id)
setSub(to)
setDrawer(false)
setPalette(false)
}
function navRow(sec: (typeof sections)[number], where: 'pin' | 'main') {
const here = sec.id === current?.id
const first = sec.subs?.[0] ?? null
const pin = pins.includes(sec.id)
return (
<div className={styles.nrow} key={`${where}-${sec.id}`}>
<a
className={styles.s}
href={toTrail({ section: sec.id, sub: first })}
data-active={here}
aria-current={here ? 'page' : undefined}
title={sec.id === 'blocking' ? blockingNow?.label : rail ? sec.label : undefined}
onClick={(e) => {
if (!plainClick(e)) return
e.preventDefault()
go(sec.id, first)
}}
>
<span className={styles.ico}>
<Icon name={ICONS[sec.id] ?? 'about'} size={16} />
</span>
<span className={styles.lbl}>{sec.label}</span>
{sec.id === 'blocking' && <BlockingMark mark={blockingNow} />}
</a>
<button
type="button"
className={styles.star}
data-on={pin}
aria-pressed={pin}
aria-label={`${pin ? 'Unpin' : 'Pin'} ${sec.label}`}
onClick={() => togglePin(sec.id)}
>
{pin ? '\u2605' : '\u2606'}
</button>
</div>
)
}
const group = GROUPS.find((g) => current != null && g.ids.includes(current.id))?.name
const crumbs = ['Home', group, current?.label, currentSub].filter((x): x is string => !!x)
return (
<HandoffContext.Provider value={handoff}>
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
<SlotProvider>
{(slot) => (
<div className={styles.shell} data-rail={rail}>
{drawer && (
<button
type="button"
className={styles.veil}
aria-label="Close menu"
onClick={() => setDrawer(false)}
/>
)}
<aside className={styles.side} data-open={drawer}>
<div className={styles.sbrand}>
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={32} height={32} />
<span className={`${styles.lbl} ${styles.bname}`}>
<b>Technitium DNS</b>
<small>console {__CONSOLE_VERSION__}</small>
</span>
<span className={`${styles.lbl} ${styles.updown}`} aria-hidden="true"><Icon name="chevronDown" size={14} /></span>
</div>
<button type="button" className={styles.sbsearch} onClick={() => setPalette(true)} aria-label="Search">
<Icon name="menu" size={14} />
<span className={styles.lbl}>Search and commands</span>
<kbd className={styles.lbl}>{'\u2318K'}</kbd>
</button>
<nav className={styles.slist} role="navigation" aria-label="Sections">
{/*
This is a navigation menu, and until now it claimed to be a `tablist`.
It was declared as tabs when the console had no addresses: twelve
`role="tab"` over a single panel. Once it gained real routes
(`app/route.ts`) the description stopped being true (the ARIA guidance is
explicit: if activating the element leads to another URL, it is a link,
not a tab), and on top of that the sub-sections hung INSIDE the `tablist`
as loose buttons, which is a child that role does not allow. It announced
"tab 3 of 12" and navigated.
Now they are links with real `href`s: they open in a new tab, they are
copied and bookmarked. The plain click is intercepted by the application
(there is no reload); the modifier click is passed through to the
browser, which is what the routes existing as files is for. The active
section is marked with `data-active` (that is visual state) and
`aria-current="page"` is reserved for ONE thing: the page you are on,
which is the sub-section when there is one.
*/}
{pinned.length > 0 && (
<div className={styles.group}>
<div className={styles.grph}>Pinned</div>
{pinned.map((sec) => navRow(sec, 'pin'))}
</div>
)}
{GROUPS.map((group) => {
const items = sections.filter((sec) => group.ids.includes(sec.id))
if (items.length === 0) return null
const closed = shut.includes(group.name)
return (
<div className={styles.group} key={group.name}>
<button
type="button"
className={styles.grph}
aria-expanded={!closed}
onClick={() => setShut((v) => (closed ? v.filter((x) => x !== group.name) : [...v, group.name]))}
>
{group.name}
<span className={styles.caret} data-shut={closed}><Icon name="chevronDown" size={12} /></span>
</button>
{!closed && items.map((sec) => navRow(sec, 'main'))}
</div>
)
})}
</nav>
{/* The server and the account, at the foot. When narrow you reach it via the drawer. */}
<div className={styles.footSide}>
{/*
Upstream's footer links, recovered.
Upstream has them in a `div#footer` hanging off the `body`, so they are
visible on ALL its screens, the login one included: Technitium, Blog,
Donate, DNS Client and GitHub. There were none here, and two of them
(technitium.com and dnsclient.net) appeared nowhere else in the console:
they had been lost entirely.
"About" is not repeated because in this redesign it is a sidebar section,
right above.
*/}
{/*
What is running, and whether there is anything newer. The server's
domain heads it because the block is about that server, which is the
pairing upstream makes in its panel title.
*/}
<Menu
label={displayName}
text={displayName}
anchor="left"
asRow
lead={<span className={styles.av} aria-hidden="true">{displayName.slice(0, 2).toUpperCase()}</span>}
>
{(close) => (
<>
<button type="button" onClick={() => { close(); open('profile') }}>
My Profile
</button>
{/* main.js:71-87 (v15.5.1) switches on `sessionData.type`:
`RemoteSSO` hides both, `RemoteLDAP` hides only the password
(the directory owns it, the 2FA is still this server's) and
`Local` or anything else shows both. */}
{session.type !== 'RemoteSSO' && session.type !== 'RemoteLDAP' && (
<button type="button" onClick={() => { close(); open('password') }}>
Change Password
</button>
)}
{session.type !== 'RemoteSSO' && (
<button type="button" onClick={() => { close(); open('twofa') }}>
Configure 2FA
</button>
)}
<button type="button" onClick={() => { close(); open('token') }}>
Create API Token
</button>
{/* index.html:71: after the account's own entries, before the
update notification. */}
<button type="button" onClick={() => { close(); open('theme') }}>
Change Theme
</button>
{/*
One entry and not two: upstream declares both and hides one
(`index.html:72-73`, `initUpdateNotificationMenu`), which is how
you write a toggle in jQuery. The literal of each is upstream's.
*/}
{silenced ? (
<button type="button" onClick={() => { close(); unsilence() }}>
Enable Update Notification
</button>
) : (
<button type="button" onClick={() => { close(); setAskSilence(true) }}>
Disable Update Notification
</button>
)}
<button type="button" onClick={() => { close(); onLogout() }}>
Logout
</button>
</>
)}
</Menu>
</div>
</aside>
<div className={styles.main}>
{/*
On a WIDE screen there is no header: it only carried the server domain and
the account menu, both pinned to the right edge, and it measured 1224×52
with 78 % of the width empty (952 px out of 1224), pushing every section's
title down to `y=76`. Both things move down to the foot of the sidebar,
which had 377 px free, 42 % of its height.
On a NARROW screen it is needed, because the sidebar is a closed drawer and
its trigger has to live outside.
*/}
<header className={styles.rtop}>
<button
type="button"
className={styles.hamburger}
aria-label="Menu"
aria-expanded={drawer}
onClick={() => (window.matchMedia('(max-width: 900px)').matches ? setDrawer((v) => !v) : toggleRail())}
>
<Icon name="menu" size={18} />
</button>
<div className={styles.crumbs} role="group" aria-label="Breadcrumb">
{crumbs.map((c, i) => (
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
))}
</div>
<Popover>
<PopoverTrigger className={styles.status} data-attention={blockingNow != null} aria-label="Server status">
<i className={styles.pdot} data-tone={blockingNow?.tone ?? 'ok'} />
<span className={styles.lbl}>{blockingNow != null ? 'Attention' : (info?.dnsServerDomain ?? 'Server')}</span>
</PopoverTrigger>
<PopoverContent align="end" className={styles.pop}>
<dl>
<dt>Server</dt><dd>{info?.dnsServerDomain ?? '-'}</dd>
<dt>Version</dt><dd>{info?.version ?? '-'}</dd>
<dt>Up since</dt><dd>{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}</dd>
<dt>Blocking</dt><dd>{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}</dd>
</dl>
<Versions
token={session.token}
serverVersion={info?.version}
domain={info?.dnsServerDomain}
markHidden={markHidden}
canCheckConsole={permissions?.DnsClient?.canView === true}
/>
</PopoverContent>
</Popover>
</header>
{/* Neither `tabpanel` nor `aria-labelledby`: ever since each section has its
own URL this is not a panel being switched, it is the page. */}
<main className={styles.body} id="panel-section">
{/*
The chrome slot: the chrome provides the PLACE, the screen provides the
control. See `ChromeSlot.tsx`: the node selector belongs to each screen,
with its memory and its options, and none of them hears about the others.
It stays empty while nobody uses it, and then it takes up nothing: it is a
`div` with no content.
*/}
<div ref={slot} className={styles.chromeSlot} />
{/* The chrome's own alert slot. Upstream's `showAlert` with no
destination is a page-level alert, and until now the chrome had
nowhere to put one. */}
<Notifier notice={chromeNotice} onClose={() => setChromeNotice(null)} />
{current?.id === 'dashboard' ? (
<Dashboard
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
/>
) : current?.id === 'dnsclient' ? (
<DnsClient
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
request={pending != null && 'dnsclient' in pending ? pending.dnsclient : undefined}
/>
) : current?.id === 'about' ? (
<About info={info} />
) : current?.id === 'apps' ? (
<Apps token={session.token} />
) : current?.id === 'cache' ? (
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} />
) : current?.id === 'blocking' ? (
<Blocking
token={session.token}
sub={currentSub}
onSubChange={setSub}
permissions={permissions}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
serverDomain={info?.dnsServerDomain}
/>
) : current?.id === 'zones' ? (
<Zones
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
canModify={permissions?.Zones?.canModify !== false}
canDelete={permissions?.Zones?.canDelete !== false}
clusterDomain={session.info?.clusterDomain}
useSoaSerialDateScheme={(recordSettings ?? session.info)?.useSoaSerialDateScheme === true}
dnssecValidation={(recordSettings ?? session.info)?.dnssecValidation !== false}
recordTtls={{
defaultRecordTtl: (recordSettings ?? session.info)?.defaultRecordTtl ?? 3600,
defaultNsRecordTtl: (recordSettings ?? session.info)?.defaultNsRecordTtl ?? 14400,
defaultSoaRecordTtl: (recordSettings ?? session.info)?.defaultSoaRecordTtl ?? 900,
}}
/>
) : current?.id === 'dhcp' ? (
<Dhcp
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
sub={currentSub ?? 'Leases'}
onSubChange={setSub}
canModify={permissions?.DhcpServer?.canModify !== false}
canDelete={permissions?.DhcpServer?.canDelete !== false}
/>
) : current?.id === 'logs' ? (
/* "Delete All Stats" lives on the Logs screen but asks for the
Dashboard's permission (WebServiceLogsApi.cs:135). Not an oversight. */
<Logs
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
sub={currentSub ?? 'View Logs'}
onSubChange={setSub}
canDeleteLogs={permissions?.Logs?.canDelete !== false}
canDeleteStats={permissions?.Dashboard?.canDelete !== false}
request={pending != null && 'logs' in pending ? pending.logs : undefined}
/>
) : current?.id === 'admin' ? (
/* No permission props on purpose: upstream hides and disables nothing
inside Administration, only the whole section (main.js:165). */
<Admin token={session.token} sub={currentSub ?? 'Sessions'} onSubChange={setSub} />
) : current?.id === 'settings' ? (
/* main.js:906-930: three different permissions in a single bar:
saving requires Settings.canModify, flushing the cache
Cache.canDelete, and backup/restore Settings.canDelete. */
<Settings
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
sub={currentSub ?? 'General'}
onSubChange={setSub}
canModify={permissions?.Settings?.canModify !== false}
canFlushCache={permissions?.Cache?.canDelete !== false}
canBackup={permissions?.Settings?.canDelete !== false}
serverDomain={session.info?.dnsServerDomain}
onServerInfo={setServerInfo}
onRecordSettings={setRecordSettings}
/>
) : null}
</main>
{/*
Upstream's links, at the foot of the page and not in the side panel.
Upstream hangs them off a `div#footer` on the `body`, below everything:
they are destinations you go to when you have finished, not navigation you
use while working. In the side panel they sat between the sections and the
account, competing with both; here they close the page, which is where
they were.
*/}
<footer className={styles.footMain}>
<FooterLinks />
</footer>
</div>
<CommandDialog open={palette} onOpenChange={setPalette} title="Search" description="Go to a section or an account action">
<Command>
<CommandInput placeholder="Go to..." />
<CommandList>
<CommandEmpty>Nothing found.</CommandEmpty>
<CommandGroup heading="Sections">
{sections.flatMap((sec) => [
<CommandItem key={sec.id} value={sec.label} onSelect={() => go(sec.id, sec.subs?.[0] ?? null)}>
<Icon name={ICONS[sec.id] ?? 'about'} size={16} /> {sec.label}
</CommandItem>,
...(sec.subs ?? []).map((sb) => (
<CommandItem key={`${sec.id}/${sb}`} value={`${sec.label} ${sb}`} onSelect={() => go(sec.id, sb)}>
<Icon name="chevronRight" size={14} /> {sec.label} / {sb}
</CommandItem>
)),
])}
</CommandGroup>
<CommandGroup heading="Account">
<CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>
<CommandItem value="Change Theme" onSelect={() => { setPalette(false); open('theme') }}>Change Theme</CommandItem>
<CommandItem value="Logout" onSelect={() => { setPalette(false); onLogout() }}>Logout</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
<MyProfile
open={modal === 'profile'}
onOpenChange={(o) => setModal(o ? 'profile' : null)}
token={session.token}
onSaved={setDisplayName}
onNotice={setChromeNotice}
/>
<ChangePassword
open={modal === 'password'}
onOpenChange={(o) => {
setModal(o ? 'password' : null)
if (!o) setForcedCurrentPassword(undefined)
}}
totpEnabled={totpEnabled}
token={session.token}
username={session.username}
currentPassword={forcedCurrentPassword}
onNotice={setChromeNotice}
/>
<Configure2FA
open={modal === 'twofa'}
onOpenChange={(o) => setModal(o ? 'twofa' : null)}
token={session.token}
username={session.username}
onChanged={setTotpEnabled}
onNotice={setChromeNotice}
/>
<CreateApiToken
open={modal === 'token'}
onOpenChange={(o) => setModal(o ? 'token' : null)}
username={session.username}
token={session.token}
/>
<ChangeTheme
open={modal === 'theme'}
onOpenChange={(o) => setModal(o ? 'theme' : null)}
/>
{/* Upstream asks with a native `confirm()`; this console asks with its own,
and the sentence is upstream's, whole. */}
<Confirm
open={askSilence}
title="Disable Update Notification"
text={
'Disabling update notification will prevent the Web Console from showing new update notification when you login. You will have to manually find out if a new update is available.\n\nAre you sure you want to disable update notification?'
}
label="Disable"
onClose={() => setAskSilence(false)}
onConfirm={silence}
/>
</div>
)}
</SlotProvider>
</ThisServerName.Provider>
</HandoffContext.Provider>
)
}