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 <noreply@anthropic.com>
805 lines
34 KiB
TypeScript
805 lines
34 KiB
TypeScript
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>
|
||
)
|
||
}
|