Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability. Store URLs are resolved from each server's own listing. The config is saved to history before an uninstall. Zip uploads are fanned out to the servers. Every target's result is audited. The console gets a row menu on the matrix, a target picker, a results dialog with Retry failed, the store and install from file in All servers mode, and single-server actions for writers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
828 lines
35 KiB
TypeScript
828 lines
35 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 { HubMode, 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 { useHubWarnings } from '../api/hub-warnings'
|
||
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 the hub (`hub/`): a read-only console over several servers. */
|
||
hub?: boolean
|
||
/** Only on the hub: the servers it holds an admin token for. */
|
||
hubWritable?: string[]
|
||
/** 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
|
||
const hub = session.info?.hub === true
|
||
const hubWarnings = useHubWarnings()
|
||
// 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 && !hub ? '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 && !hub ? '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 ?? ''}>
|
||
<HubMode.Provider value={hub}>
|
||
<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) => (
|
||
<>
|
||
{/* The hub serves reads only: no profile, password, 2FA or tokens. */}
|
||
{!hub && (
|
||
<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. */}
|
||
{!hub && session.type !== 'RemoteSSO' && session.type !== 'RemoteLDAP' && (
|
||
<button type="button" onClick={() => { close(); open('password') }}>
|
||
Change Password
|
||
</button>
|
||
)}
|
||
{!hub && session.type !== 'RemoteSSO' && (
|
||
<button type="button" onClick={() => { close(); open('twofa') }}>
|
||
Configure 2FA
|
||
</button>
|
||
)}
|
||
{!hub && (
|
||
<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 || hubWarnings.length > 0} aria-label="Server status">
|
||
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
|
||
<span className={styles.lbl}>
|
||
{hubWarnings.length > 0
|
||
? `${hubWarnings.length} ${hubWarnings.length === 1 ? 'server' : 'servers'} not answering`
|
||
: blockingNow != null ? `Blocking ${blockingNow.text.toLowerCase()}` : (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>
|
||
{hubWarnings.length > 0 && (
|
||
<ul className={styles.warnings} aria-label="Servers not answering">
|
||
{hubWarnings.map((w) => (<li key={w}>{w}</li>))}
|
||
</ul>
|
||
)}
|
||
<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} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={session.info?.hubWritable ?? []} />
|
||
) : 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">
|
||
{!hub && <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>
|
||
</HubMode.Provider>
|
||
</ThisServerName.Provider>
|
||
</HandoffContext.Provider>
|
||
)
|
||
}
|