Move the console onto ReUI PRO

Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:06:06 +07:00
commit f3531c64f9
680 files changed
+139836

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
/*
The sidebar's mark on Blocking: a small pill at the entry's end. Its tones are the
Overview block's (`blocking-state.ts`, `blockingMark`): amber for a pause the user
can lift, neutral otherwise. On the active entry, which is already amber, it keeps
only its outline, in the entry's own ink.
*/
.mark {
margin-left: auto;
padding: 0 var(--s-3);
border: 1px solid var(--line);
border-radius: var(--r-full);
background: var(--pan2);
color: var(--mute);
font-size: var(--t-1);
font-weight: var(--text-weight);
letter-spacing: var(--text-tracking);
text-transform: uppercase;
/* No taller than the entry's own line, so the entry keeps its height. */
line-height: 1.3;
}
.mark[data-tone='acc'] { background: var(--acc-bg); border-color: var(--acc-bd); color: var(--acc-ink); }
[data-active='true'] > .mark { background: transparent; border-color: currentColor; color: inherit; }
+26
View File
@@ -0,0 +1,26 @@
import type { BlockingMark as Mark } from '../screens/blocking/blocking-state'
import text from '../ui/text.module.css'
import styles from './BlockingMark.module.css'
/*
The sidebar's mark on Blocking: whether blocking is disabled or paused, from
anywhere in the console. OURS; what it says and when it reads are in
`screens/blocking/blocking-state.ts`. The only count or state the sidebar carries.
The link's accessible name gains the sentence (`Blocking, paused until 14:30`)
through hidden text, so the link keeps a name made of its own content; the short
word is drawn and hidden from assistive technology, which already has the
sentence. The Shell puts the same sentence in the link's `title`.
*/
export function BlockingMark({ mark }: { mark: Mark | null }) {
if (mark == null) return null
return (
<>
<span className={text.sr}>{mark.label.replace(/^Blocking/, '')}</span>
<span className={styles.mark} data-tone={mark.tone} aria-hidden="true">
{mark.text}
</span>
</>
)
}
+70
View File
@@ -0,0 +1,70 @@
/*
The chrome slot.
It exists because the design puts the node selector at the top, and the selector
**belongs to each screen**: the Dashboard remembers its choice in
`dashboardClusterNode` and `Settings` its own in `settingsClusterNode`, each with
its own options. A single selector in the chrome would have to invent a shared
memory and would change the behaviour of eight screens at a stroke.
So the chrome provides the PLACE and the screen provides the CONTROL. What these
tests protect is exactly that separation.
*/
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SlotProvider, InChrome } from './ChromeSlot'
describe('the slot in the chrome', () => {
it('what a screen puts in it shows up in the slot', () => {
render(
<SlotProvider>
{(slot) => (
<>
<div data-testid="slot" ref={slot} />
<main>
<InChrome>
<button>Selector</button>
</InChrome>
</main>
</>
)}
</SlotProvider>,
)
expect(screen.getByTestId('slot')).toContainElement(screen.getByRole('button', { name: 'Selector' }))
})
/* With no slot yet it does not blow up: it simply draws nothing. */
it('with no slot it draws nothing and does not throw', () => {
render(
<SlotProvider>
{() => (
<InChrome>
<button>Selector</button>
</InChrome>
)}
</SlotProvider>,
)
expect(screen.queryByRole('button', { name: 'Selector' })).not.toBeInTheDocument()
})
/*
And the slot stays EMPTY if nobody uses it, which is the case on seven of the
eight screens. Without this, they would all gain a blank space at the top.
*/
it('the slot stays empty when no screen uses it', () => {
render(
<SlotProvider>
{(slot) => (
<>
<div data-testid="slot" ref={slot} />
<main>nothing to hang</main>
</>
)}
</SlotProvider>,
)
expect(screen.getByTestId('slot')).toBeEmptyDOMElement()
})
})
+54
View File
@@ -0,0 +1,54 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
/*
A chrome slot, so that a screen can put a control at the TOP without the chrome
having to know which one.
Why it is needed: the Dashboard design puts the node selector at the top of the
chrome, "because it does not filter a region, it reframes the screen". But the
selector **belongs to the screen**, not to the chrome: the Dashboard remembers its
choice in `dashboardClusterNode` and `Settings` its own in `settingsClusterNode`,
and each offers its own options. A single selector in the chrome would have to
invent a shared memory the console does not have, and would change the behaviour
of eight screens at a stroke.
So the chrome provides **the place** and the screen provides **the control, its
state and its memory**. When the other seven come to migrate, each will bring its
own here without any of them hearing about the others.
## Where the slot is, and why not in a top bar
The design draws it in a top bar at 1440. **This console has no such bar**:
`Shell.module.css:48` leaves it at `display:none` when wide, and that is reasoned:
it measured 1224×52 with 78 % of the width empty and pushed every section title
down to `y=76`. Reviving it to hang a control off would be undoing a measured
decision in order to follow a drawing to the letter.
The slot goes, instead, **above the content and on the left**, which is the
position the drawing gives it relative to what is below. When narrow it sits just
under the bar that does exist.
*/
const Slot = createContext<HTMLElement | null>(null)
export function SlotProvider({
children,
}: {
/** Receives the `ref` to hang on the slot. */
children: (ref: (n: HTMLElement | null) => void) => ReactNode
}) {
/*
State and not a `ref`: with a `ref` the first render has no node and whoever
portals into it never hears that there is one now; there is no re-render to
report it.
*/
const [node, setNode] = useState<HTMLElement | null>(null)
return <Slot.Provider value={node}>{children(setNode)}</Slot.Provider>
}
/** Whatever a screen wants to put there. With no slot yet, it draws nothing. */
export function InChrome({ children }: { children: ReactNode }) {
const node = useContext(Slot)
if (node == null) return null
return createPortal(children, node)
}
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen, within } from '@testing-library/react'
import { Shell, type ShellSession } from './Shell'
import { ThemeProvider } from '../theme/ThemeProvider'
import * as client from '../api/client'
import * as settings from '../api/settings'
import type { DnsSettings } from '../api/settings'
import { settingsChanged } from '../lib/settings-signal'
import { forgetRoot } from './route'
/*
The sidebar's mark on Blocking (`BlockingMark.tsx`, `blocking-state.ts`): drawn when
blocking is disabled or paused, with the time it comes back in the link's name and
title, and nothing when the session cannot read the setting.
*/
function session(settingsView = true, settingsModify = true): ShellSession {
const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
return {
token: 'tok',
displayName: 'Administrator',
username: 'admin',
info: {
version: '15.6',
uptimestamp: '2026-10-15T08:00:00Z',
dnsServerDomain: 'dns.example.net',
permissions: Object.fromEntries(all.map((s) => [
s,
s === 'Settings'
? { canView: settingsView, canModify: settingsModify, canDelete: false }
: { canView: true, canModify: true, canDelete: true },
])),
},
}
}
function mount(s = session()) {
return render(<ThemeProvider><Shell session={s} onLogout={() => {}} /></ThemeProvider>)
}
const entry = () => within(screen.getByRole('navigation', { name: 'Sections' })).getByRole('link', { name: /^Blocking/ })
beforeEach(() => {
localStorage.clear()
forgetRoot()
window.history.replaceState(null, '', '/about/')
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('Shell: the Blocking mark', () => {
it('none while blocking is on', async () => {
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as DnsSettings)
mount()
await vi.waitFor(() => expect(read).toHaveBeenCalled())
expect(entry()).toHaveAccessibleName('Blocking')
expect(entry()).not.toHaveAttribute('title')
})
it('disabled: Off, and the name says so', async () => {
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
mount()
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled'))
expect(entry()).toHaveTextContent('Off')
expect(entry()).toHaveAttribute('title', 'Blocking, disabled')
})
it('paused: the time it comes back in the name and the title, and it goes when the pause ends', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date(2026, 9, 15, 14, 0, 0))
const till = new Date(2026, 9, 15, 14, 5, 0).toISOString()
vi.spyOn(settings, 'getSettings')
.mockResolvedValueOnce({ enableBlocking: false, temporaryDisableBlockingTill: till } as DnsSettings)
.mockResolvedValue({ enableBlocking: true } as DnsSettings)
mount()
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, paused until 14:05'))
expect(entry()).toHaveAttribute('title', 'Blocking, paused until 14:05')
expect(entry()).toHaveTextContent('Paused')
await act(() => vi.advanceTimersByTimeAsync(5 * 60_000 + 1_000))
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking'))
})
it('follows a change made elsewhere in the console', async () => {
vi.spyOn(settings, 'getSettings')
.mockResolvedValueOnce({ enableBlocking: true } as DnsSettings)
.mockResolvedValue({ enableBlocking: false } as DnsSettings)
mount()
await vi.waitFor(() => expect(settings.getSettings).toHaveBeenCalledTimes(1))
act(() => settingsChanged())
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled'))
})
it('without Settings.canView it reads nothing and marks nothing', async () => {
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
mount(session(false, false))
act(() => settingsChanged())
expect(read).not.toHaveBeenCalled()
expect(entry()).toHaveAccessibleName('Blocking')
})
})
+194
View File
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { StrictMode } from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Shell, type ShellSession } from './Shell'
import { ThemeProvider } from '../theme/ThemeProvider'
import * as client from '../api/client'
import * as dashboard from '../api/dashboard'
import * as dnsclient from '../api/dnsclient'
import * as apps from '../api/apps'
import * as logs from '../api/logs'
import type { InstalledApp } from '../api/apps'
import { forgetRoot } from './route'
/*
The jumps between screens, end to end through the Shell (`app/handoff.ts`):
the row menu asks, the Shell switches section, and the target screen arrives
filled in and runs its query, once.
*/
const chart = { labels: ['a', 'b'], datasets: [{ label: 'Total', data: [1, 2] }] }
const STATS = {
stats: {
totalQueries: 10, totalNoError: 10, totalServerFailure: 0, totalNxDomain: 0,
totalRefused: 0, totalAuthoritative: 0, totalRecursive: 10, totalCached: 0,
totalBlocked: 0, totalDropped: 0, totalClients: 2,
zones: 1, cachedEntries: 0, allowedZones: 0, blockedZones: 0,
allowListZones: 0, blockListZones: 0,
},
mainChartData: chart,
queryResponseChartData: chart,
queryTypeChartData: chart,
protocolTypeChartData: chart,
topClients: [{ name: '10.0.0.1', domain: 'pc.home.test', hits: 12, rateLimited: false }],
topDomains: [{ name: 'github.com', hits: 7 }],
topBlockedDomains: [],
}
const APP: InstalledApp = {
name: 'Query Logs (Sqlite)',
description: 'Logs all incoming DNS requests.',
version: '8.0',
dnsApps: [
{
classPath: 'QueryLogsSqlite.App',
description: 'Logs queries.',
recordDataTemplate: null,
isAppRecordRequestHandler: false,
isRequestController: false,
isAuthoritativeRequestHandler: false,
isRequestBlockingHandler: false,
isQueryLogger: true,
isQueryLogs: true,
isPostProcessor: false,
},
],
}
const ENTRY = {
rowNumber: 1,
timestamp: '2026-08-26T05:32:14Z',
clientIpAddress: '10.0.0.1',
protocol: 'Udp',
responseType: 'Recursive',
rcode: 'NoError',
qname: 'api.github.com',
qtype: 'HTTPS',
qclass: 'IN',
answer: null,
}
function session(canViewDnsClient = true): ShellSession {
const sections = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
return {
token: 'tok',
displayName: 'Administrator',
username: 'admin',
type: 'Local',
info: {
version: '15.6',
uptimestamp: '2026-08-25T13:07:31Z',
dnsServerDomain: 'dns.example.net',
permissions: Object.fromEntries(
sections.map((s) => [
s,
{ canView: s === 'DnsClient' ? canViewDnsClient : true, canModify: true, canDelete: true },
]),
),
},
}
}
function mount(s: ShellSession = session()) {
return render(
<StrictMode>
<ThemeProvider>
<Shell session={s} onLogout={() => {}} />
</ThemeProvider>
</StrictMode>,
)
}
let resolve: ReturnType<typeof vi.spyOn>
let query: ReturnType<typeof vi.spyOn>
beforeEach(() => {
localStorage.clear()
forgetRoot()
window.history.replaceState(null, '', '/')
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'ok', data: STATS } as never)
resolve = vi.spyOn(dnsclient, 'resolve').mockResolvedValue({
kind: 'ok',
data: { status: 'ok', response: { result: { answer: 'ok' } } },
} as never)
vi.spyOn(apps, 'listApps').mockResolvedValue({
kind: 'ok',
data: { status: 'ok', response: { apps: [APP] } },
} as never)
query = vi.spyOn(logs, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: { pageNumber: 1, totalPages: 1, totalEntries: 1, entries: [ENTRY] } },
} as never)
})
afterEach(() => vi.restoreAllMocks())
async function pick(row: string, item: string) {
await userEvent.click(await screen.findByRole('button', { name: `Actions for ${row}` }))
await userEvent.click(screen.getByRole('menuitem', { name: item }))
}
const sidebar = () => screen.getByRole('navigation', { name: 'Sections' })
describe('Shell: the jumps of the row menus', () => {
it('Query DNS Server opens DNS Client filled in and runs the query once', async () => {
mount()
await pick('github.com', 'Query DNS Server')
expect(await screen.findByRole('heading', { name: 'DNS Client' })).toBeInTheDocument()
await waitFor(() => expect(resolve).toHaveBeenCalledTimes(1))
expect(resolve.mock.calls[0][1]).toMatchObject({
server: 'this-server',
domain: 'github.com',
type: 'A',
protocol: 'UDP',
dnssec: false,
eDnsClientSubnet: '',
node: '',
})
expect(window.location.pathname).toBe('/dnsclient/')
})
it('coming back to DNS Client later finds a clean form and runs nothing', async () => {
mount()
await pick('github.com', 'Query DNS Server')
await waitFor(() => expect(resolve).toHaveBeenCalledTimes(1))
await userEvent.click(within(sidebar()).getByRole('link', { name: 'Dashboard' }))
await userEvent.click(within(sidebar()).getByRole('link', { name: 'DNS Client' }))
expect(await screen.findByLabelText('Domain')).toHaveValue('')
expect(resolve).toHaveBeenCalledTimes(1)
})
it('Show Query Logs opens Logs › Query Logs filtered by the client and queries', async () => {
mount()
await pick('10.0.0.1', 'Show Query Logs')
expect(await screen.findByText('api.github.com')).toBeInTheDocument()
expect(query).toHaveBeenCalledTimes(1)
expect(query.mock.calls[0][1]).toMatchObject({ qname: '', clientIpAddress: '10.0.0.1', node: '' })
expect(screen.getByLabelText('Client IP Address')).toHaveValue('10.0.0.1')
expect(window.location.pathname).toBe('/logs/query-logs/')
})
it('from a Query Logs row, Query DNS Server goes on to DNS Client with the type of the row', async () => {
mount()
await pick('github.com', 'Show Query Logs')
expect(await screen.findByText('api.github.com')).toBeInTheDocument()
expect(query.mock.calls[0][1]).toMatchObject({ qname: 'github.com', clientIpAddress: '' })
await pick('row 1', 'Query DNS Server')
expect(await screen.findByRole('heading', { name: 'DNS Client' })).toBeInTheDocument()
await waitFor(() => expect(resolve).toHaveBeenCalledTimes(1))
expect(resolve.mock.calls[0][1]).toMatchObject({ domain: 'api.github.com', type: 'HTTPS' })
})
it('a section the user cannot see is not opened', async () => {
mount(session(false))
await pick('github.com', 'Query DNS Server')
expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
expect(resolve).not.toHaveBeenCalled()
})
})
+174
View File
@@ -0,0 +1,174 @@
/* Side panel with nested sub-items. Decided by measuring: 72 px of chrome
against 163 with tabs on top, and the second row of stacked tabs disappears. */
.shell { min-height:100vh; background:var(--bg); color:var(--ink); display:grid; grid-template-columns:240px 1fr; align-items:start; }
.side { background:var(--pan); border-right:1px solid var(--line); align-self:stretch; position:sticky; top:0; height:100vh; overflow:auto; display:flex; flex-direction:column; }
.sbrand { display:flex; align-items:center; gap: var(--s-6); padding: 0 var(--s-10); height:48px; border-bottom:1px solid var(--line); font-weight: 600; letter-spacing:-.01em; color:var(--ink); }
/*
Technitium's logo, not a "T" in an amber square.
The initial was our own invention: the logo exists, it travels in
`public/img/logo.png` since the scaffolding and upstream uses it. Replacing
someone else's product's mark with a letter of our own is exactly what a fork
must not do.
*/
.mark { width:22px; height:22px; flex:none; }
/*
The items sat 1 px from each other with 7 px of vertical padding: the list read
as a block of text, not as twelve destinations. And there was no separation
between groups at all, so "About" weighed the same as "Zones".
Now there are three groups, separated by space and not by a line: what gets
operated daily (Dashboard … DNS Client), what gets configured (Settings, DHCP,
Administration) and what gets consulted (Logs, About).
*/
.slist { padding: var(--s-9) var(--s-6); display:flex; flex-direction:column; gap: var(--s-1); }
.group { display:flex; flex-direction:column; gap: var(--s-1); }
.group + .group { margin-top: var(--s-9); }
.s { display:flex; text-decoration:none; align-items:center; gap: var(--s-7); height:32px; padding: 0 var(--s-5); border-radius: var(--r-2); font:400 var(--t-4)/1.3 var(--font-ui); color:var(--mute); background:none; border:0; cursor:pointer; text-align:left; white-space:nowrap; width:100%; transition: background var(--t-fast), color var(--t-fast); }
.s:hover { background:var(--hover); color:var(--ink); }
.s[data-active='true'] { background:var(--pan2); color:var(--ink); font-weight: 500; }
.s[data-active='true'] .ico { opacity:1; }
.ico { display:inline-flex; flex:none; opacity:.85; }
.sub { display:flex; flex-direction:column; gap: var(--s-1); margin: var(--s-3) 0 var(--s-5) var(--s-11); padding-left: var(--s-7); border-left:1px solid var(--line); }
.s2 { display:block; text-decoration:none; padding: var(--s-4) var(--s-7); border-radius: var(--r-2); font:400 var(--t-4)/1.3 var(--font-ui); color:var(--mute); background:none; border:0; cursor:pointer; text-align:left; white-space:nowrap; transition: background var(--t-fast), color var(--t-fast); }
.s2:hover { color:var(--ink); }
.s2[aria-current='page'] { color:var(--acc-ink); font-weight: 550; background:var(--pan2); box-shadow: var(--edge); }
.main { min-width:0; display:flex; flex-direction:column; min-height:100vh; }
/*
`--stuck-top`: how much of the window's top is already taken by something pinned,
for whatever pins inside the content (the section index's strip, a dense form's
block titles). Nothing on a wide screen; the top bar below 900.
*/
.shell { --stuck-top: 0px; }
/*
The header ONLY exists on a narrow screen.
It carried the server's domain and the account menu, both stuck to the right
edge: 1224×52 with 78 % of the width empty (952 px out of 1224) and pushing every
section's title down to `y=76`. Its two tenants now live at the foot of the side
panel, which had 377 px free (42 % of its height). On narrow screens it is
needed, because the side panel is a closed drawer and its trigger has to be
outside it.
*/
.rtop { display:none; }
/* At the foot of the side panel, hence `margin-top:auto`: that is what pushes it
down however short the list of sections is. */
.footSide { margin-top:auto; padding: var(--s-8) var(--s-9); border-top:1px solid var(--line); display:flex; flex-direction:column; gap: var(--s-4); }
/* It opens upwards: it sits at the foot of the window, not under a header. */
.body { padding: var(--s-11) var(--s-11) var(--s-11); min-width:0; flex:1; }
/* A screen that reads a file (View Logs) is as tall as the window and not as its
content: it marks its root and gets a flex column to grow into, down to the
footer. No other screen is touched. */
.body:has(> [data-fills-window]) { display:flex; flex-direction:column; }
/* The page's foot: it drops to the bottom when the section is short and comes
after the content when it is long, which is where upstream's `div#footer` is. */
.footMain {
flex: none;
padding: var(--s-9) var(--s-11);
border-top: 1px solid var(--line2);
text-align: center;
font-size: var(--t-2);
line-height: 1.9;
}
/*
Below 900 px the side panel stops taking a column and becomes a drawer.
This is NOT parity with upstream: upstream does not declare a `viewport`, so on a
phone it draws at ~980 virtual px and is dealt with by zooming. We do declare it,
which means we promise to adapt; without this the promise was false and the
twelve sections overflowed. And the most frequent use case for a home DNS is
looking at it from a phone when something has stopped resolving.
*/
/* The drawer's trigger and the header's mark only exist on a narrow screen; on a
wide one the side panel is already in sight. */
.hamburger,
.topBrand {
display: none;
}
/* The padding goes here and not inside the media query: without declaring it,
the browser puts in its own (1px 6px), and that is a value we did not choose. */
.hamburger { padding: 0; }
@media (max-width: 900px) {
.shell { grid-template-columns: 1fr; --stuck-top: var(--top-bar); }
.rtop {
display: flex; align-items: center; gap: var(--s-6);
padding: 0 var(--s-9); height: var(--top-bar);
border-bottom: 1px solid var(--line); background: var(--pan);
box-shadow: var(--edge); font-size: var(--t-4); color: var(--mute);
position: sticky; top: 0; z-index: var(--layer-sticky);
}
.side {
position: fixed;
z-index: var(--layer-drawer);
top: 0;
left: 0;
width: 264px;
max-width: 84vw;
transform: translateX(-100%);
transition: transform 0.18s ease;
box-shadow: var(--shadow-modal);
}
.side[data-open='true'] { transform: none; }
.veil {
position: fixed;
inset: 0;
z-index: var(--layer-veil);
background: var(--veil);
border: 0;
}
.hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-right: auto;
padding: 0;
background: none;
border: 1px solid var(--line);
border-radius: var(--r-2);
color: var(--ink);
font-size: var(--t-5);
cursor: pointer;
}
.topBrand {
display: flex;
align-items: center;
gap: var(--s-5);
font-weight: 600;
color: var(--ink);
}
.rtop { padding: 0 var(--s-9); gap: var(--s-7); }
.body { padding: var(--s-9); }
}
@media (prefers-reduced-motion: reduce) {
.side { transition: none; }
}
/*
The chrome slot: where a screen can put a control that reframes everything below
it: today only the Dashboard's node selector.
It goes above the content and on the left, which is the position the Dashboard
design gives it relative to what is below. NOT in a top bar: this console has no
top bar when wide, and removing it was a measured decision (see above). Reviving
it to hang a control off would be undoing that in order to follow a drawing.
`:empty` is what keeps it from taking up room when nobody uses it: without it,
seven of eight screens would gain a blank gap.
*/
.chromeSlot { display: flex; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
.chromeSlot:empty { display: none; }
+665
View File
@@ -0,0 +1,665 @@
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 { 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: string[][] = [
['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'],
['settings', 'dhcp', 'admin'],
['logs', 'about'],
]
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 [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])
return (
<HandoffContext.Provider value={handoff}>
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
<SlotProvider>
{(slot) => (
<div className={styles.shell}>
{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={22} height={22} /> Technitium
</div>
<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.
*/}
{GROUPS.map((group, g) => (
<div className={styles.group} key={g}>
{sections.filter((sec) => group.includes(sec.id)).map((sec) => {
const active = sec.id === current?.id
const first = sec.subs?.[0] ?? null
return (
<div key={sec.id}>
<a
className={styles.s}
href={toTrail({ section: sec.id, sub: first })}
data-active={active}
aria-current={active ? 'page' : undefined}
title={sec.id === 'blocking' ? blockingNow?.label : undefined}
onClick={(e) => {
if (!plainClick(e)) return
e.preventDefault()
setActive(sec.id)
setSub(first)
setDrawer(false)
}}
>
<span className={styles.ico}>
<Icon name={ICONS[sec.id] ?? 'about'} size={16} />
</span>
{sec.label}
{sec.id === 'blocking' && <BlockingMark mark={blockingNow} />}
</a>
{/*
The sub-items are NOT here any more. The design puts a
section's sub-navigation under its title, for a reason:
nested here, nine panes run out of room the moment the rail
drops to 60 px. What the sidebar gets back is
its own shape: twelve entries, always the same twelve, instead
of fourteen, eighteen or twenty-one depending on where you are.
See `ui/SubTabs`.
*/}
</div>
)
})}
</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.
*/}
<Versions
token={session.token}
serverVersion={info?.version}
domain={info?.dnsServerDomain}
markHidden={markHidden}
canCheckConsole={permissions?.DnsClient?.canView === true}
/>
<Menu label={displayName} text={displayName} anchor="left" asRow>
{(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={() => setDrawer((v) => !v)}
>
<Icon name="menu" size={18} />
</button>
<span className={styles.topBrand}>
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
</span>
</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>
<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>
)
}
+127
View File
@@ -0,0 +1,127 @@
/*
The versions block, at the foot of the side panel.
It is the same idea NetBird uses at the bottom-left of its dashboard: the
components that make up what you are looking at, each with the version it is
running, in a quiet framed block that is out of the way but never off screen.
Here there are two, and they are genuinely two different things that update
separately: the DNS server, which Technitium ships, and this console, which does
not come with it.
Upstream keeps the server's domain in the panel title next to "DNS Server". Same
pairing here: the domain heads the block that talks about that server.
*/
.block {
border: 1px solid var(--line);
border-radius: var(--r-3);
background: var(--pan2);
padding: var(--s-5) var(--s-6);
display: flex;
flex-direction: column;
gap: var(--s-3);
}
/* The server's domain wraps at its dots only (`ui/DomainName`): `break-all` cut it
wherever the card's edge fell, mid-label. A name with no dot, which DomainName
leaves as plain text, still breaks rather than leave the card. */
.host {
font-family: var(--font-mono);
overflow-wrap: break-word;
font-size: var(--t-1);
color: var(--faint);
padding-bottom: var(--s-3);
border-bottom: 1px solid var(--line2);
margin-bottom: var(--s-2);
}
/* Label left, version right: the eye reads down the versions in one column.
When a mark leaves no room beside the label, the version and its mark move to
the next line, still on the right, rather than cut "Web Console" in two. */
.row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--s-2) var(--s-5);
font-size: var(--t-2);
}
.name { color: var(--mute); white-space: nowrap; }
.ver {
display: inline-flex;
align-items: center;
gap: var(--s-4);
margin-left: auto;
}
.num { font-family: var(--font-mono); color: var(--ink); }
/*
The mark rides on the version it is about, which is the whole point of putting
it here: an update to the DNS server and an update to this console are not the
same news, and a notice floating on its own could not say which one it meant.
Amber and not the red upstream uses: in this console red is what destroys
(Delete Zones, Flush Cache) and an available update destroys nothing.
*/
.mark {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px var(--s-4) 1px var(--s-3);
border: 1px solid var(--acc-ink);
border-radius: 999px;
background: transparent;
color: var(--acc-ink);
font: inherit;
font-size: var(--t-1);
line-height: 1.6;
cursor: pointer;
}
.mark:hover,
.mark:focus-visible { background: color-mix(in srgb, var(--acc) 14%, transparent); }
.dot { width: 5px; height: 5px; border-radius: 50%; background: var(--acc-ink); }
/* Inside the dialog. */
.jump {
display: flex; align-items: baseline; gap: var(--s-5);
margin-bottom: var(--s-7); font-family: var(--font-mono);
}
.from { color: var(--mute); }
.arrow { color: var(--faint); }
.to { color: var(--acc-ink); font-weight: 600; }
.message { margin-bottom: var(--s-7); }
.links { display: flex; flex-wrap: wrap; gap: var(--s-7); }
/* The console's update commands: one line each, read and copied whole. They
are long, so they wrap between words rather than push the dialog wider. */
/* The command and its Copy button on one line; the command takes the width. */
.commandRow {
display: flex;
align-items: center;
gap: var(--s-4);
margin: calc(-1 * var(--s-5)) 0 var(--s-7);
}
.commandRow > button { flex: none; }
.command {
display: block;
flex: 1 1 auto;
min-width: 0;
padding: var(--s-4) var(--s-5);
border: 1px solid var(--line);
border-radius: var(--r-2);
background: var(--pan2);
font-family: var(--font-mono);
font-size: var(--t-1);
overflow-wrap: break-word;
user-select: all;
}
/* Upstream's two `Note!`, after the links: they explain, they do not change the
decision. Same rule as every other `Note!` in the console. */
.notes { display: grid; gap: var(--s-5); margin-top: var(--s-9); }
+265
View File
@@ -0,0 +1,265 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Versions } from './Versions'
import * as userApi from '../api/user'
import * as consoleUpdate from './console-update'
import * as copyApi from '../lib/copy-text'
import { wholeText } from '../test/whole-text'
afterEach(() => vi.restoreAllMocks())
function withUpdate(response: Partial<userApi.UpdateInfo> = {}) {
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({
kind: 'ok',
data: { status: 'ok', response: { updateAvailable: true, ...response } },
} as never)
}
function withoutUpdate() {
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({
kind: 'ok',
data: { status: 'ok', response: { updateAvailable: false } },
} as never)
}
const mark = { name: 'DNS Server update available' } as const
describe('Versions', () => {
it('reports both versions and the server it is talking about', async () => {
withoutUpdate()
render(<Versions token="t" serverVersion="15.4" domain="dns.example.net" />)
expect(screen.getByText('DNS Server')).toBeInTheDocument()
expect(screen.getByText('15.4')).toBeInTheDocument()
expect(screen.getByText('Web Console')).toBeInTheDocument()
expect(screen.getByText(wholeText('dns.example.net'))).toBeInTheDocument()
})
/* The session may arrive without info; a missing version is said, not faked. */
it('says nothing it does not know', async () => {
withoutUpdate()
render(<Versions token="t" />)
expect(screen.getByText('—')).toBeInTheDocument()
})
it('does not mark anything when the server is up to date', async () => {
withoutUpdate()
render(<Versions token="t" serverVersion="15.4" />)
await new Promise((r) => setTimeout(r, 0))
expect(screen.queryByRole('button', mark)).not.toBeInTheDocument()
})
/* While the notice is silenced the endpoint is not even called and
`checkForUpdate` answers `skipped`: nothing may be marked from that. */
it('does not mark anything while the notice is silenced', async () => {
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({ kind: 'skipped' })
render(<Versions token="t" serverVersion="15.4" />)
await new Promise((r) => setTimeout(r, 0))
expect(screen.queryByRole('button', mark)).not.toBeInTheDocument()
})
it('a failed check is not an update', async () => {
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
render(<Versions token="t" serverVersion="15.4" />)
await new Promise((r) => setTimeout(r, 0))
expect(screen.queryByRole('button', mark)).not.toBeInTheDocument()
})
it('marks the server version when there is an update', async () => {
withUpdate()
render(<Versions token="t" serverVersion="15.4" />)
expect(await screen.findByRole('button', mark)).toBeInTheDocument()
})
it('opens the detail with versions, message and the three links', async () => {
withUpdate({
currentVersion: '15.4',
updateVersion: '16.0',
updateMessage: 'Fixes a resolver crash.',
downloadLink: 'https://example.test/dl',
instructionsLink: 'https://example.test/how',
changeLogLink: 'https://example.test/log',
})
render(<Versions token="t" serverVersion="15.4" />)
await userEvent.click(await screen.findByRole('button', mark))
expect(screen.getByText('16.0')).toBeInTheDocument()
expect(screen.getByText('Fixes a resolver crash.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Download Now!' })).toHaveAttribute('href', 'https://example.test/dl')
expect(screen.getByRole('link', { name: 'Update Instructions' })).toHaveAttribute('href', 'https://example.test/how')
expect(screen.getByRole('link', { name: 'Read Change Logs' })).toHaveAttribute('href', 'https://example.test/log')
})
/*
Three literals that were missing or reworded, found checking About against
upstream on 2026-09-04: the update flow had never been walked against upstream
item by item, because the earlier check covered the chrome and not this dialog.
*/
it('names each of the two versions, which the arrow only says to the eye', async () => {
withUpdate({ currentVersion: '15.4', updateVersion: '16.0' })
render(<Versions token="t" serverVersion="15.4" />)
await userEvent.click(await screen.findByRole('button', mark))
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveTextContent('Current Version: 15.4')
expect(dialog).toHaveTextContent('Update Version: 16.0')
})
it('carries upstream two `Note!`, which were nowhere in the console', async () => {
withUpdate({ updateMessage: 'x' })
render(<Versions token="t" serverVersion="15.4" />)
await userEvent.click(await screen.findByRole('button', mark))
expect(
screen.getByText('It is highly recommended to Backup Settings before installing the update.'),
).toBeInTheDocument()
expect(
screen.getByText(/You will have to refresh this web page manually after updating/),
).toBeInTheDocument()
})
/* Upstream writes this title itself when the feed carries none. */
it('falls back to upstream own wording when there is no title', async () => {
withUpdate({ updateMessage: 'x' })
render(<Versions token="t" serverVersion="15.4" />)
await userEvent.click(await screen.findByRole('button', mark))
expect(screen.getByRole('heading', { name: 'New Update Available!' })).toBeInTheDocument()
})
/* A link to `null` would be a dead control, which is worse than no control. */
it('hides each link the server does not send', async () => {
withUpdate({ downloadLink: 'https://example.test/dl' })
render(<Versions token="t" serverVersion="15.4" />)
await userEvent.click(await screen.findByRole('button', mark))
expect(screen.getByRole('link', { name: 'Download Now!' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Update Instructions' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Read Change Logs' })).not.toBeInTheDocument()
})
})
describe('Versions: the console mark', () => {
const consoleMark = { name: 'Web Console update available' } as const
function serverCheck(response: Partial<userApi.UpdateInfo>) {
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({
kind: 'ok',
data: { status: 'ok', response: { updateAvailable: false, dnsServerEnableCheckForUpdate: true, ...response } },
} as never)
}
function consoleRelease(latest = '1.4.0') {
return vi.spyOn(consoleUpdate, 'checkConsoleUpdate').mockResolvedValue({ latest, server: '15.5' })
}
const settle = () => new Promise((r) => setTimeout(r, 0))
it('shows the console mark when a newer console exists', async () => {
serverCheck({})
consoleRelease()
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
expect(await screen.findByRole('button', consoleMark)).toBeInTheDocument()
expect(screen.queryByRole('button', mark)).not.toBeInTheDocument()
})
it('opens the console dialog with the version jump and the commands', async () => {
serverCheck({})
consoleRelease()
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
await userEvent.click(await screen.findByRole('button', consoleMark))
const dialog = screen.getByRole('dialog')
expect(screen.getByRole('heading', { name: 'New Web Console Version Available!' })).toBeInTheDocument()
expect(dialog).toHaveTextContent(`Current Version: ${__CONSOLE_VERSION__}`)
expect(dialog).toHaveTextContent('Update Version: 1.4.0')
expect(screen.getByRole('link', { name: 'Release Notes' })).toHaveAttribute(
'href',
'https://github.com/byGarcia/technitium-console/releases/tag/v1.4.0',
)
expect(dialog).toHaveTextContent(
'curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh',
)
expect(dialog).toHaveTextContent(
'docker compose pull technitium-console && docker compose up -d technitium-console',
)
})
it('each command has a Copy button that copies it whole and says so', async () => {
serverCheck({})
consoleRelease()
const copy = vi.spyOn(copyApi, 'copyText').mockResolvedValue(true)
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
await userEvent.click(await screen.findByRole('button', consoleMark))
await userEvent.click(screen.getByRole('button', { name: 'Copy the installer command' }))
expect(copy).toHaveBeenCalledWith(
'curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh',
expect.any(HTMLElement),
)
expect(await screen.findByRole('button', { name: 'Copy the installer command' })).toHaveTextContent('Copied')
await userEvent.click(screen.getByRole('button', { name: 'Copy the Docker command' }))
expect(copy).toHaveBeenLastCalledWith(
'docker compose pull technitium-console && docker compose up -d technitium-console',
expect.any(HTMLElement),
)
})
it('does not mark a console that is already the latest', async () => {
serverCheck({})
consoleRelease(__CONSOLE_VERSION__)
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
await settle()
expect(screen.queryByRole('button', consoleMark)).not.toBeInTheDocument()
})
it('does not check without DNS Client permission', async () => {
serverCheck({})
const spy = consoleRelease()
render(<Versions token="t" serverVersion="15.6.2" />)
await settle()
expect(spy).not.toHaveBeenCalled()
})
it("does not check when the server's check is off", async () => {
serverCheck({ dnsServerEnableCheckForUpdate: false })
const spy = consoleRelease()
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
await settle()
expect(spy).not.toHaveBeenCalled()
})
it('does not check when the notice is silenced', async () => {
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({ kind: 'skipped' })
const spy = consoleRelease()
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
await settle()
expect(spy).not.toHaveBeenCalled()
})
it('hides the console mark when the session silences the notice', async () => {
serverCheck({})
consoleRelease()
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole markHidden />)
await settle()
expect(screen.queryByRole('button', consoleMark)).not.toBeInTheDocument()
})
/* The server's version reaches the Shell after the first render. The server's
own check must not run again when it does: each run is a line in its log. */
it("asks the server once when the server's version arrives later", async () => {
serverCheck({})
consoleRelease()
const spy = vi.mocked(userApi.checkForUpdate)
const { rerender } = render(<Versions token="t" canCheckConsole />)
await settle()
rerender(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
expect(await screen.findByRole('button', consoleMark)).toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(1)
})
it('names the two marks apart', async () => {
serverCheck({ updateAvailable: true, updateVersion: '16.0' })
consoleRelease()
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
expect(await screen.findByRole('button', consoleMark)).toBeInTheDocument()
expect(screen.getByRole('button', mark)).toBeInTheDocument()
})
})
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useRef, useState } from 'react'
import { checkForUpdate, type UpdateInfo } from '../api/user'
import { checkConsoleUpdate, newerConsole } from './console-update'
import { Dialog } from '../ui/Dialog'
import { External } from '../ui/External'
import { Alert } from '../ui/Alert'
import { DomainName } from '../ui/DomainName'
import { Button } from '../ui/Button'
import { copyText } from '../lib/copy-text'
import styles from './Versions.module.css'
import text from '../ui/text.module.css'
/*
What is running, and whether there is anything newer.
Two facts that used to be scattered: the server's version lived only on the
About screen, this console's version lived nowhere at all, and the update notice
(which upstream shows from EVERY screen: `index.html:141`, in the panel heading,
outside the tab panes) was not shown anywhere. The check existed in
`api/user.ts` and the only thing it drove was one line inside About.
Putting the mark on the version it concerns is not decoration: the DNS server
and this console update separately and by different means, so a notice floating
on its own could not say which of the two it meant.
Nothing here decides anything the server has not already decided. Every field is
shown, or hidden when the server sends it null, exactly as `main.js` does. And
the silencing preference is not re-read: `checkForUpdate` refuses to call the
endpoint while it is on, which is upstream's behaviour and not this component's
decision.
*/
export function Versions({ token, serverVersion, domain, markHidden = false, canCheckConsole = false }: {
token: string
serverVersion?: string
domain?: string
/* The session may use DNS Client, which the console's own check goes through
(`console-update.ts`). Without it the server would only log a denial. */
canCheckConsole?: boolean
/*
The chrome silenced the notice in THIS session, so the mark goes.
Upstream hides its link the moment you disable the notification
(`main.js:722`) and never shows it again, not even on re-enabling: you get it
back by logging in again. Replicated as it stands.
*/
markHidden?: boolean
}) {
const [info, setInfo] = useState<UpdateInfo | null>(null)
const [open, setOpen] = useState(false)
const [consoleLatest, setConsoleLatest] = useState<string | null>(null)
const [consoleOpen, setConsoleOpen] = useState(false)
/* The server's *Check for update* switch and a silenced notice (with which
`checkForUpdate` answers `skipped`) turn both notices off. There is no
setting of the console's own. */
const [consoleCheckOn, setConsoleCheckOn] = useState(false)
/* Once per session token, as before: each call is a line in the server's log,
so it does not depend on anything that arrives after the first render. */
useEffect(() => {
let alive = true
void (async () => {
const r = await checkForUpdate(token)
if (!alive || r.kind !== 'ok') return
const d = r.data as { response?: UpdateInfo }
if (d.response?.updateAvailable) setInfo(d.response)
setConsoleCheckOn(d.response?.dnsServerEnableCheckForUpdate === true)
})()
return () => {
alive = false
}
}, [token])
/* Runs again when the server's version arrives, which costs nothing: the
check keeps its answer for a day (`console-update.ts`). */
useEffect(() => {
if (!consoleCheckOn || !canCheckConsole) return
let alive = true
void (async () => {
const release = await checkConsoleUpdate(token)
if (alive) setConsoleLatest(newerConsole(release, __CONSOLE_VERSION__, serverVersion))
})()
return () => {
alive = false
}
}, [token, consoleCheckOn, canCheckConsole, serverVersion])
/* main.js: when the feed sends no title, upstream writes this one itself. */
const title = info?.updateTitle ?? 'New Update Available!'
return (
<>
<div className={styles.block}>
{domain && <div className={styles.host}><DomainName name={domain} /></div>}
<div className={styles.row}>
<span className={styles.name}>DNS Server</span>
{/* The mark goes BEFORE the number so that the two version numbers
keep their right edges aligned in one column, which is the whole
reason for reading them stacked. */}
<span className={styles.ver}>
{info && !markHidden && (
<button
type="button"
className={styles.mark}
onClick={() => setOpen(true)}
title={title}
aria-label="DNS Server update available"
>
<span className={styles.dot} aria-hidden="true" />
Update
</button>
)}
<span className={styles.num}>{serverVersion ?? '—'}</span>
</span>
</div>
{/*
This console's version, injected at build time from package.json.
The browser cannot ask GitHub for a newer one: the server's CSP is
`default-src 'self'` with no `connect-src`. So the server asks instead,
through DNS Client, the project's DNS-over-HTTPS endpoint
(`console-update.ts`, `update-check/`), at most once a day.
*/}
<div className={styles.row}>
<span className={styles.name}>Web Console</span>
<span className={styles.ver}>
{consoleLatest && !markHidden && (
<button
type="button"
className={styles.mark}
onClick={() => setConsoleOpen(true)}
title={CONSOLE_TITLE}
aria-label="Web Console update available"
>
<span className={styles.dot} aria-hidden="true" />
Update
</button>
)}
<span className={styles.num}>{__CONSOLE_VERSION__}</span>
</span>
</div>
</div>
{info && (
<Dialog open={open} onOpenChange={setOpen} title={title} size="compact">
{/*
The two numbers keep the arrow, and get back the two names upstream
writes beside them: `Current Version:` and `Update Version:`.
Found checking About against upstream, 2026-09-04: the arrow says which
is which to the eye and to nobody else. Read aloud it was "15.4 → 16.0", two numbers
with no way to tell the one you are running from the one you are being
offered, and this is the dialog whose whole job is that distinction.
The names go hidden rather than drawn so the comparison keeps the shape
the chrome design decided.
*/}
{info.currentVersion && info.updateVersion && (
<div className={styles.jump}>
<span className={styles.from}>
<span className={text.sr}>Current Version: </span>
{info.currentVersion}
</span>
<span className={styles.arrow} aria-hidden="true">→</span>
<span className={styles.to}>
<span className={text.sr}>Update Version: </span>
{info.updateVersion}
</span>
</div>
)}
{info.updateMessage && <p className={styles.message}>{info.updateMessage}</p>}
{/* Upstream hides each of the three on its own when it comes null. */}
<div className={styles.links}>
{info.downloadLink && <External href={info.downloadLink}>Download Now!</External>}
{info.instructionsLink && <External href={info.instructionsLink}>Update Instructions</External>}
{/* `Read Change Logs`, which is what upstream calls it. It said
`Change Log`: a literal reworded, caught checking About against upstream. */}
{info.changeLogLink && <External href={info.changeLogLink}>Read Change Logs</External>}
</div>
{/*
Upstream's two `Note!`, and they were nowhere in this console.
They are not decoration: one tells you to back up before updating and
the other that the page has to be reloaded by hand afterwards, which is
the difference between an update that goes well and one that leaves you
looking at a console that is no longer the one running.
*/}
<div className={styles.notes}>
<Alert type="info" title="Note!">
It is highly recommended to Backup Settings before installing the update.
</Alert>
<Alert type="info" title="Note!">
You will have to refresh this web page manually after updating the DNS Server so that
new UI changes are loaded.
</Alert>
</div>
</Dialog>
)}
{/*
OURS: the console's own notice. Same shape as the server's above; what to
run is copied from the README, which is where the two commands live.
*/}
{consoleLatest && (
<Dialog open={consoleOpen} onOpenChange={setConsoleOpen} title={CONSOLE_TITLE} size="compact">
<div className={styles.jump}>
<span className={styles.from}>
<span className={text.sr}>Current Version: </span>
{__CONSOLE_VERSION__}
</span>
<span className={styles.arrow} aria-hidden="true">→</span>
<span className={styles.to}>
<span className={text.sr}>Update Version: </span>
{consoleLatest}
</span>
</div>
<p className={styles.message}>To update, run the installer again:</p>
<Command text={INSTALL_COMMAND} label="Copy the installer command" />
<p className={styles.message}>On Docker:</p>
<Command text={DOCKER_COMMAND} label="Copy the Docker command" />
<div className={styles.links}>
<External href={`https://github.com/byGarcia/technitium-console/releases/tag/v${consoleLatest}`}>
Release Notes
</External>
</div>
<div className={styles.notes}>
<Alert type="info" title="Note!">
You will have to refresh this web page manually after updating so that the new console is loaded.
</Alert>
</div>
</Dialog>
)}
</>
)
}
const CONSOLE_TITLE = 'New Web Console Version Available!'
const INSTALL_COMMAND =
'curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh'
const DOCKER_COMMAND = 'docker compose pull technitium-console && docker compose up -d technitium-console'
/*
OURS: a command with its Copy button. The console cannot run it (it is a web page
and the server offers no call that replaces its files), so the shortest way is
one click to copy and a paste on the server. The button says "Copied" for two
seconds; if the browser refuses, the line stays selectable as before.
*/
function Command({ text: command, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false)
// Inside the dialog, so its focus trap leaves the copy's selection alone.
const row = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!copied) return
const t = window.setTimeout(() => setCopied(false), 2000)
return () => window.clearTimeout(t)
}, [copied])
return (
<div className={styles.commandRow} ref={row}>
<code className={styles.command}>{command}</code>
<Button size="sm" aria-label={label} onClick={() => void copyText(command, row.current ?? undefined).then(setCopied)}>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
/*
Where the console hangs from.
It cannot be assumed to be `/`: the server honours `X-Forwarded-Prefix` by mounting
a `PathBase` (`DnsWebService.cs:1943-1945`), so behind a proxy the console can live
at `/dns/`. And ever since the routes became real (`/settings/logging/`), relative
paths do not work either: `api/status` from there requests
`/settings/logging/api/status`, which is a 404. Verified in the browser before
fixing it.
Each generated `index.html` declares its own route in a `<meta>`, and the root
comes from subtracting those segments from the `pathname`.
*/
function compute(): string {
const route = document.querySelector('meta[name="route"]')?.getAttribute('content')
/*
Repeated slashes are collapsed. The server serves the front page for `//`
too, and a root of `//` would turn every API call into `//api/…`, a
protocol-relative URL, i.e. the host `api`, with the session token in
`Authorization`. Upstream never has this problem because it asks for `api/…`
relative to the document (security audit 2026-09-30).
*/
const trail = window.location.pathname.replace(/\/{2,}/g, '/')
if (route == null || route === '') return trail.endsWith('/') ? trail : trail + '/'
const spare = route.split('/').length
const parts = trail.split('/').filter(Boolean)
const prefix = parts.slice(0, Math.max(0, parts.length - spare))
return prefix.length === 0 ? '/' : `/${prefix.join('/')}/`
}
/*
It is computed ONCE and frozen. The `<meta>` belongs to the document the server
served and does not change on navigation: as soon as the application pushes
`/settings/logging/` onto a document that said `dashboard`, subtracting its
segments would give a different root on every hop. The root is a property of where
the console is mounted, not of where the user is.
*/
let cache: string | null = null
export function appRoot(): string {
cache ??= compute()
return cache
}
/** For the tests only, which change root between cases. */
export function forgetRoot(): void {
cache = null
}
/** An endpoint's URL, hanging from the real root. */
export function urlApi(trail: string): string {
return appRoot() + trail
}
/**
* The same for a file from `public/` (the logo, the `loader.gif`s). A relative
* `src` also breaks from a two-level route: `img/logo.png` at
* `/settings/logging/` requests `/settings/logging/img/logo.png`.
*/
export function publicUrl(trail: string): string {
return appRoot() + trail
}
+162
View File
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as dnsclient from '../api/dnsclient'
import {
checkConsoleUpdate,
compareVersions,
CONSOLE_UPDATE_KEY,
newerConsole,
parseRecord,
} from './console-update'
describe('parseRecord', () => {
it('reads the record', () => {
expect(parseRecord('v=1 latest=1.4.0 server=15.5')).toEqual({ latest: '1.4.0', server: '15.5' })
})
it('ignores a record without its version', () => {
expect(parseRecord('latest=1.4.0 server=15.5')).toBeNull()
})
it('ignores keys it does not know', () => {
expect(parseRecord('v=1 latest=1.4.0 server=15.5 future=x')).toEqual({ latest: '1.4.0', server: '15.5' })
})
it('takes two-part versions', () => {
expect(parseRecord('v=1 latest=1.4 server=15.5')).toEqual({ latest: '1.4', server: '15.5' })
})
it('refuses anything that is not a version', () => {
expect(parseRecord('v=1 latest=<b>1</b> server=15.5')).toBeNull()
expect(parseRecord('v=1 latest=1.4.0')).toBeNull()
})
})
describe('compareVersions', () => {
it('compares field by field', () => {
expect(compareVersions('1.4.0', '1.3.9')).toBeGreaterThan(0)
expect(compareVersions('15.6.2', '15.5')).toBeGreaterThan(0)
expect(compareVersions('15.5', '15.5.0')).toBe(0)
})
it('puts a prerelease before its release and after the one before', () => {
expect(compareVersions('1.4.0-rc.1', '1.4.0')).toBeLessThan(0)
expect(compareVersions('1.4.0-rc.1', '1.3.2')).toBeGreaterThan(0)
})
})
describe('newerConsole', () => {
const release = { latest: '1.4.0', server: '15.5' }
it('notifies a newer release', () => {
expect(newerConsole(release, '1.3.1', '15.6.2')).toBe('1.4.0')
})
it('stays quiet for the same or an older release', () => {
expect(newerConsole(release, '1.4.0', '15.6.2')).toBeNull()
expect(newerConsole(release, '1.5.0', '15.6.2')).toBeNull()
})
it('stays quiet when the server is below the minimum', () => {
expect(newerConsole(release, '1.3.1', '15.4')).toBeNull()
})
it('stays quiet without a server version or a record', () => {
expect(newerConsole(release, '1.3.1', undefined)).toBeNull()
expect(newerConsole(null, '1.3.1', '15.6.2')).toBeNull()
})
})
describe('checkConsoleUpdate', () => {
const NOW = 1_800_000_000_000
const HOUR = 3_600_000
function answers(text: string | null) {
return vi.spyOn(dnsclient, 'resolve').mockResolvedValue({
kind: 'ok',
data: {
status: 'ok',
response: {
result: { Answer: text === null ? [] : [{ Type: 'TXT', RDATA: { Text: text } }] },
},
},
} as never)
}
beforeEach(() => localStorage.clear())
afterEach(() => vi.restoreAllMocks())
it('asks the server with the DoH endpoint and no node', async () => {
const spy = answers('v=1 latest=1.4.0 server=15.5')
expect(await checkConsoleUpdate('t', NOW)).toEqual({ latest: '1.4.0', server: '15.5' })
expect(spy).toHaveBeenCalledWith('t', {
server: 'https://technitium-console.pages.dev/dns-query',
domain: 'latest.technitium-console.pages.dev',
type: 'TXT',
protocol: 'HTTPS',
dnssec: false,
})
})
it('reuses a fresh result', async () => {
const spy = answers('v=1 latest=1.4.0 server=15.5')
await checkConsoleUpdate('t', NOW)
expect(await checkConsoleUpdate('t', NOW + HOUR)).toEqual({ latest: '1.4.0', server: '15.5' })
expect(spy).toHaveBeenCalledTimes(1)
})
it('asks again after a day', async () => {
const spy = answers('v=1 latest=1.4.0 server=15.5')
await checkConsoleUpdate('t', NOW)
await checkConsoleUpdate('t', NOW + 24 * HOUR)
expect(spy).toHaveBeenCalledTimes(2)
})
it('caches a failed check for a day', async () => {
const spy = vi.spyOn(dnsclient, 'resolve').mockResolvedValue({ kind: 'error', message: 'Access was denied.' } as never)
expect(await checkConsoleUpdate('t', NOW)).toBeNull()
expect(await checkConsoleUpdate('t', NOW + HOUR)).toBeNull()
expect(spy).toHaveBeenCalledTimes(1)
expect(JSON.parse(localStorage.getItem(CONSOLE_UPDATE_KEY)!)).toEqual({ at: NOW, release: null })
})
/* A server that cannot reach the endpoint holds the request for up to ten
seconds: a reload or a second tab in that time must not ask again. */
it('asks once while a check is still running', async () => {
let finish: (v: unknown) => void = () => {}
const spy = vi.spyOn(dnsclient, 'resolve').mockReturnValue(new Promise((r) => (finish = r)) as never)
const first = checkConsoleUpdate('t', NOW)
expect(await checkConsoleUpdate('t', NOW + 1000)).toBeNull()
expect(spy).toHaveBeenCalledTimes(1)
finish({ kind: 'error', message: 'timed out' })
await first
})
it('asks again when the stored release is not a valid one', async () => {
const spy = answers('v=1 latest=1.4.0 server=15.5')
localStorage.setItem(CONSOLE_UPDATE_KEY, JSON.stringify({ at: NOW, release: { latest: '<b>9</b>', server: '15.5' } }))
expect(await checkConsoleUpdate('t', NOW + HOUR)).toEqual({ latest: '1.4.0', server: '15.5' })
expect(spy).toHaveBeenCalledTimes(1)
})
/* A clock that ran ahead and was corrected must not silence the check. */
it('asks again when the stored time is in the future', async () => {
const spy = answers('v=1 latest=1.4.0 server=15.5')
localStorage.setItem(CONSOLE_UPDATE_KEY, JSON.stringify({ at: NOW + 30 * 24 * HOUR, release: null }))
await checkConsoleUpdate('t', NOW)
expect(spy).toHaveBeenCalledTimes(1)
})
it('ignores an answer without TXT', async () => {
answers(null)
expect(await checkConsoleUpdate('t', NOW)).toBeNull()
})
it('checks when storage cannot be read', async () => {
const spy = answers('v=1 latest=1.4.0 server=15.5')
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked')
})
expect(await checkConsoleUpdate('t', NOW)).toEqual({ latest: '1.4.0', server: '15.5' })
expect(spy).toHaveBeenCalledTimes(1)
})
})
+131
View File
@@ -0,0 +1,131 @@
import { resolve } from '../api/dnsclient'
/*
OURS: whether a newer release of this console exists.
The browser cannot ask GitHub: the server serves the console with
`default-src 'self'` and no `connect-src` (DnsWebService.cs, `OnPrepareResponse`).
So the console asks its own server, with DNS Client, to resolve one TXT record
against the project's DNS-over-HTTPS endpoint (`update-check/` in this repo),
which answers `v=1 latest=X.Y.Z server=A.B`. It is an exception to "zero
functionality", with its limits written down in CONVENTIONS.md.
At most once a day per browser, whatever the outcome: a failed check leaves a
line in the server's log, and a day is the most that is acceptable.
*/
export const CONSOLE_UPDATE_SERVER = 'https://technitium-console.pages.dev/dns-query'
export const CONSOLE_UPDATE_NAME = 'latest.technitium-console.pages.dev'
export const CONSOLE_UPDATE_KEY = 'consoleUpdateCheck'
const DAY = 86_400_000
const VERSION = /^\d+\.\d+(\.\d+)?$/
export interface ConsoleRelease {
latest: string
server: string
}
/* The record's format is frozen: `v=1` and the keys this console knows. Others
are ignored, so that the format can grow without silencing this one. */
export function parseRecord(text: string): ConsoleRelease | null {
const fields = new Map<string, string>()
for (const pair of text.trim().split(/\s+/)) {
const i = pair.indexOf('=')
if (i > 0) fields.set(pair.slice(0, i), pair.slice(i + 1))
}
const latest = fields.get('latest')
const server = fields.get('server')
if (fields.get('v') !== '1' || !latest || !server) return null
if (!VERSION.test(latest) || !VERSION.test(server)) return null
return { latest, server }
}
/* Numeric, field by field, missing fields counting as 0. A `-suffix` is a
prerelease: before the same numbers without one. */
export function compareVersions(a: string, b: string): number {
const [aCore, aPre] = a.split('-', 2)
const [bCore, bPre] = b.split('-', 2)
const aParts = aCore.split('.').map(Number)
const bParts = bCore.split('.').map(Number)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const d = (aParts[i] ?? 0) - (bParts[i] ?? 0)
if (d !== 0) return d
}
if (aPre && !bPre) return -1
if (!aPre && bPre) return 1
return 0
}
/* The version to announce, or null. Never one made for a newer server than the
one running: the CHANGELOG says to update the console with the server, and
the server's own notice already asks for that first. */
export function newerConsole(
release: ConsoleRelease | null,
installed: string,
serverVersion: string | undefined,
): string | null {
if (!release || !serverVersion) return null
if (compareVersions(release.latest, installed) <= 0) return null
if (compareVersions(serverVersion, release.server) < 0) return null
return release.latest
}
interface Stored {
at: number
release: ConsoleRelease | null
}
/* What comes back from storage is checked like an answer: same-origin script
or an older format can have written anything there. */
function readStored(): Stored | null {
try {
const raw = localStorage.getItem(CONSOLE_UPDATE_KEY)
const stored = raw ? (JSON.parse(raw) as Partial<Stored>) : null
if (!stored || typeof stored.at !== 'number') return null
if (stored.release === null) return { at: stored.at, release: null }
const r = stored.release
if (!r || typeof r.latest !== 'string' || typeof r.server !== 'string') return null
const release = parseRecord(`v=1 latest=${r.latest} server=${r.server}`)
return release ? { at: stored.at, release } : null
} catch {
return null
}
}
function store(value: Stored): void {
try {
localStorage.setItem(CONSOLE_UPDATE_KEY, JSON.stringify(value))
} catch {
// Storage refused: the next load checks again, which is all this costs.
}
}
export async function checkConsoleUpdate(token: string | null, now = Date.now()): Promise<ConsoleRelease | null> {
const stored = readStored()
// A time ahead of now (a clock that was corrected) is not a fresh answer.
if (stored && stored.at <= now && now - stored.at < DAY) return stored.release
// Taken before asking, not after: a server that cannot reach the endpoint
// holds the request for up to ten seconds, and a reload or a second tab in
// that time must not ask again. Until it answers, the last result stands.
store({ at: now, release: stored?.release ?? null })
// No `node`: every node of a cluster serves its own console, so the one the
// browser is connected to is the one that matters.
const r = await resolve(token, {
server: CONSOLE_UPDATE_SERVER,
domain: CONSOLE_UPDATE_NAME,
type: 'TXT',
protocol: 'HTTPS',
dnssec: false,
})
let release: ConsoleRelease | null = null
if (r.kind === 'ok') {
const result = r.data.response?.result as { Answer?: { Type?: string; RDATA?: { Text?: unknown } }[] } | undefined
const txt = result?.Answer?.find((a) => a.Type === 'TXT')?.RDATA?.Text
if (typeof txt === 'string') release = parseRecord(txt)
}
store({ at: now, release })
return release
}
+62
View File
@@ -0,0 +1,62 @@
/*
Upstream's footer links.
They live in a `div#footer` hanging directly off the `body` (not off `#pageLogin`
nor `#pageMain`), so in upstream they are visible on EVERY screen: the login one
and the whole console. Here they were missing entirely, and two of them
(`technitium.com` and `dnsclient.net`) appeared nowhere else, so they had been lost
from the product.
The list lives in a module of its own because two different places paint it (the
sidebar and the login screen), and a repeated list of links is a list that ends up
half-updated as soon as somebody touches one.
"About" is not there: in upstream it is the footer's sixth link and here it is a
sidebar section, so it is already reachable.
*/
export interface FooterLink {
text: string
href: string
/*
An alternative accessible name, only when the visible one collides with another
link on the same screen. It happens with "DNS Client": it is at once a sidebar
section (`/dnsclient/`) and Technitium's other product (`dnsclient.net`), so
anyone navigating by list of links heard the same thing twice pointing at
different places. The name starts with the visible text, which is what the
"label in name" criterion requires.
*/
name?: string
}
export const FOOTER: FooterLink[] = [
{ text: 'Technitium', href: 'https://technitium.com/' },
{ text: 'Blog', href: 'https://blog.technitium.com/' },
{ text: 'Donate', href: 'https://go.technitium.com/?id=35' },
{ text: 'DNS Client', href: 'https://dnsclient.net/', name: 'DNS Client at dnsclient.net' },
{ text: 'GitHub', href: 'https://github.com/TechnitiumSoftware/DnsServer' },
]
/*
The theme credit. It is NOT upstream's: it is the only thing in the footer this
console adds, and that is why it lives outside `FOOTER` and outside the list
`dev/check-parity-controls.mjs` compares. Whoever reads this has to be able to
tell at a glance what is parity and what is our addition.
It goes on its own line and not as a sixth link: the five above are PRODUCT
destinations and this is an authorship credit. Put in the same row, with the same
pipes, it would read as one more Technitium site.
*/
export const THEME_CREDIT = {
/*
The handle, not the name: this is the identity the work is published under.
The label before it is "Theme:" and not "Theme by", which is what it said when
the credit carried a person's name. With the handle it would have read "Theme
by byGarcia", and the stutter is the handle's own "by" colliding with the
label's.
*/
text: 'byGarcia',
href: 'https://github.com/byGarcia',
/* Starts with the visible text, which is what "label in name" requires. */
name: 'byGarcia on GitHub',
}
+82
View File
@@ -0,0 +1,82 @@
import { createContext, useContext } from 'react'
import { AGGREGATE } from '../ui/ClusterNodeSelect'
/*
Upstream's two jumps from one screen to another, which the row menus of the
Dashboard, its Top Stats modal and Query Logs call:
· `queryDnsServer(domain, type, node)` (dnsclient.js:231-257) fills DNS Client
(this server, the domain, the type or "A", UDP, no EDNS Client Subnet, DNSSEC
validation unchecked), switches to it and RUNS the query.
· `showQueryLogs(domain, clientIp, node, rcode, responseType)` (logs.js:629-663)
resets the Query Logs form, fills the domain and/or the client, and since
v15.6.2 the response code and/or the response type (the Dashboard's count
cards, logs.js:21-24), switches to Logs › Query Logs and RUNS the query.
Both pick the target's cluster node only when they were given one and it is not
the aggregate; otherwise the target keeps the node it had, which here is the one
it starts on.
The screen that offers the jump asks through `useHandoff()`. The Shell owns the
switch: it moves to the section and hands the request to the target screen as a
prop, once. The target fills itself from it when it mounts and runs the query.
*/
export interface DnsClientRequest {
domain: string
type: string
/** Empty: keep the node selector where it starts. */
node: string
}
/*
The form's fields a jump fills besides the domain and the client, each by its name in
that form and written in the form's own format (`From`/`To` are `datetime-local`
values in the browser's zone). `rcode` and `responseType` are upstream's, from the
Dashboard's count cards. The rest is what the Dashboard's charts add (deviation 7 in
CONVENTIONS.md, this console's own): the time window of a point of the Queries chart,
and the value of a slice or a bar, over the period. Upstream's jump never carries a
time window, a protocol or a query type.
*/
export interface QueryLogsFilters {
start?: string
end?: string
responseType?: string
rcode?: string
protocol?: string
qtype?: string
}
export interface QueryLogsRequest {
domain: string | null
clientIp: string | null
/** Empty: keep the node selector where it starts. */
node: string
filters?: QueryLogsFilters
}
export interface Handoff {
queryDnsServer: (domain: string, type: string | null, node: string | null) => void
showQueryLogs: (
domain: string | null,
clientIp: string | null,
node: string | null,
filters?: QueryLogsFilters,
) => void
/** Whether Query Logs is there to land on. The row menus offer the jump anyway,
* as upstream does, and it does nothing; a chart only invites the click when it
* will go somewhere. Absent means it is. */
canShowQueryLogs?: boolean
}
export const HandoffContext = createContext<Handoff | null>(null)
/** `null` outside the Shell, where there is nowhere to go. */
export function useHandoff(): Handoff | null {
return useContext(HandoffContext)
}
/* `if ((node != null) && (node != "cluster"))`, in both functions. */
export function targetNode(node: string | null): string {
return node != null && node !== AGGREGATE ? node : ''
}
+282
View File
@@ -0,0 +1,282 @@
import { describe, expect, it, afterEach, vi } from 'vitest'
import { SECTIONS } from './sections'
import { STATIC_ROUTES } from './static-routes'
import {
toTrail,
toSlug,
writeRoute,
readRoute,
forgetRoot,
appRoot,
translateLegacyRoute,
navigateTo,
plainClick,
} from './route'
/** Serves the document as the server would: in its folder and with its meta. */
function servedAt(trail: string, route: string | null = null) {
document.head.querySelector('meta[name="route"]')?.remove()
if (route != null) {
const m = document.createElement('meta')
m.setAttribute('name', 'route')
m.setAttribute('content', route)
document.head.appendChild(m)
}
window.history.replaceState(null, '', trail)
forgetRoot()
}
afterEach(() => {
servedAt('/')
})
describe('toSlug', () => {
it('it turns the upstream label into something that fits in a URL', () => {
expect(toSlug('Leases')).toBe('leases')
expect(toSlug('Web Service')).toBe('web-service')
expect(toSlug('View Logs')).toBe('view-logs')
// The `&` disappears instead of becoming a dash, so as not to leave `proxy--forwarders`
expect(toSlug('Proxy & Forwarders')).toBe('proxy-forwarders')
expect(toSlug('Optional Protocols')).toBe('optional-protocols')
})
it('every sub-section yields a distinct slug within its section', () => {
for (const s of SECTIONS) {
if (s.subs == null) continue
const slugs = s.subs.map(toSlug)
expect(new Set(slugs).size, `slug collision in ${s.id}: ${slugs.join(', ')}`).toBe(slugs.length)
}
})
})
/*
The console can hang off any prefix: the server honours `X-Forwarded-Prefix` by
mounting a `PathBase`. The root is deduced by subtracting from the `pathname` the
segments the document itself declares in its `<meta>`.
*/
describe('appRoot', () => {
it('on the front page, the root is the front page', () => {
servedAt('/')
expect(appRoot()).toBe('/')
})
it('on a one-level path', () => {
servedAt('/zones/', 'zones')
expect(appRoot()).toBe('/')
})
it('on a two-level path', () => {
servedAt('/settings/logging/', 'settings/logging')
expect(appRoot()).toBe('/')
})
it('and behind a proxy with a prefix, which is what all this is about', () => {
servedAt('/dns/settings/logging/', 'settings/logging')
expect(appRoot()).toBe('/dns/')
})
it('with a two-segment prefix', () => {
servedAt('/home/dns/zones/', 'zones')
expect(appRoot()).toBe('/home/dns/')
})
it('a doubled slash on the front page stays on this origin', () => {
// The server serves the front page for `//`. Taken as it stands, the root
// would be `//` and every API call `//api/…`: a request to the host `api`
// with the session token in `Authorization`.
servedAt(window.location.origin + '//')
expect(appRoot()).toBe('/')
servedAt(window.location.origin + '//dns//')
expect(appRoot()).toBe('/dns/')
})
})
describe('readRoute', () => {
it('on the front page there is no route, and it starts from whatever the Shell says', () => {
servedAt('/')
expect(readRoute(SECTIONS)).toBeNull()
})
it('it reads section and sub-section', () => {
servedAt('/admin/cluster/', 'admin/cluster')
expect(readRoute(SECTIONS)).toEqual({ section: 'admin', sub: 'Cluster' })
})
it('it returns the original label, not the slug', () => {
servedAt('/settings/proxy-forwarders/', 'settings/proxy-forwarders')
expect(readRoute(SECTIONS)).toEqual({ section: 'settings', sub: 'Proxy & Forwarders' })
})
it('it reads it the same behind a prefix', () => {
servedAt('/dns/admin/cluster/', 'admin/cluster')
expect(readRoute(SECTIONS)).toEqual({ section: 'admin', sub: 'Cluster' })
})
it('an unknown section does not resolve', () => {
servedAt('/nonexistent/', 'nonexistent')
expect(readRoute(SECTIONS)).toBeNull()
})
it('an unknown sub does NOT bring the section down: it falls to the first', () => {
servedAt('/settings/nonexistent-either/', 'settings/nonexistent-either')
expect(readRoute(SECTIONS)).toEqual({ section: 'settings', sub: null })
})
it('a section hidden by permissions does not resolve, even though it exists', () => {
servedAt('/admin/cluster/', 'admin/cluster')
expect(readRoute(SECTIONS.filter((s) => s.id !== 'admin'))).toBeNull()
})
})
describe('toTrail', () => {
it('it omits the sub when there is none, and always ends in a slash', () => {
servedAt('/')
expect(toTrail({ section: 'zones', sub: null })).toBe('/zones/')
})
it('and it slugs it when there is one', () => {
servedAt('/')
expect(toTrail({ section: 'logs', sub: 'Query Logs' })).toBe('/logs/query-logs/')
})
it('it honours the proxy prefix', () => {
servedAt('/dns/zones/', 'zones')
expect(toTrail({ section: 'settings', sub: 'TSIG' })).toBe('/dns/settings/tsig/')
})
})
describe('legacy routes', () => {
it('/allowed/ and /blocked/ still exist as folders', () => {
expect(STATIC_ROUTES).toEqual(expect.arrayContaining(['allowed', 'blocked', 'blocking/rules']))
})
it('/blocked/ becomes /blocking/rules/?rule=blocked, replacing the entry', () => {
servedAt('/blocked/', 'blocked')
const before = window.history.length
expect(translateLegacyRoute()).toBe(true)
expect(window.location.pathname + window.location.search).toBe('/blocking/rules/?rule=blocked')
expect(window.history.length).toBe(before)
})
it('behind a prefix', () => {
servedAt('/dns/allowed/', 'allowed')
expect(translateLegacyRoute()).toBe(true)
expect(window.location.pathname + window.location.search).toBe('/dns/blocking/rules/?rule=allowed')
})
it('leaves any other route alone', () => {
servedAt('/zones/', 'zones')
expect(translateLegacyRoute()).toBe(false)
expect(window.location.pathname).toBe('/zones/')
})
it('an unknown sub of blocking falls to its first tab', () => {
servedAt('/blocking/zzz/', 'blocking/zzz')
expect(readRoute(SECTIONS)).toEqual({ section: 'blocking', sub: null })
})
})
/*
A link from inside a screen to another section. It used to be a bare `<a href>`
and reloaded the whole console; `navigateTo` pushes the route and announces it
with the same `popstate` the back button fires, which is what the Shell already
follows.
*/
describe('navigateTo', () => {
it('pushes the route and fires popstate', () => {
servedAt('/blocking/overview/', 'blocking/overview')
// Spied, not counted: `history.length` does not move in jsdom.
const push = vi.spyOn(window.history, 'pushState')
const heard = vi.fn()
window.addEventListener('popstate', heard)
navigateTo({ section: 'logs', sub: 'Query Logs' })
window.removeEventListener('popstate', heard)
expect(push).toHaveBeenCalledTimes(1)
push.mockRestore()
expect(window.location.pathname).toBe('/logs/query-logs/')
expect(heard).toHaveBeenCalledTimes(1)
})
it('behind a prefix', () => {
servedAt('/dns/blocking/lists/', 'blocking/lists')
navigateTo({ section: 'settings', sub: 'Blocking' })
expect(window.location.pathname).toBe('/dns/settings/blocking/')
})
})
describe('plainClick', () => {
const click = { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, defaultPrevented: false }
it('a left click with no modifier is the application one', () => {
expect(plainClick(click)).toBe(true)
})
it('any modifier, another button or a click already handled is the browser one', () => {
expect(plainClick({ ...click, ctrlKey: true })).toBe(false)
expect(plainClick({ ...click, metaKey: true })).toBe(false)
expect(plainClick({ ...click, shiftKey: true })).toBe(false)
expect(plainClick({ ...click, altKey: true })).toBe(false)
expect(plainClick({ ...click, button: 1 })).toBe(false)
expect(plainClick({ ...click, defaultPrevented: true })).toBe(false)
})
})
describe('writeRoute', () => {
it('it leaves the address bar on the requested path', () => {
servedAt('/')
writeRoute({ section: 'dhcp', sub: 'Leases' }, true)
expect(window.location.pathname).toBe('/dhcp/leases/')
})
it('it does not touch the history if the path is already the current one', () => {
servedAt('/zones/', 'zones')
const before2 = window.history.length
writeRoute({ section: 'zones', sub: null })
expect(window.location.pathname).toBe('/zones/')
expect(window.history.length).toBe(before2)
})
/* 34 since Blocking (three tabs) replaced Allowed and Blocked (none): eleven
sections and twenty-three sub-sections. The two legacy folders are not
sections and are not in this walk. */
it('what gets written reads back the same, across all 34 routes', () => {
servedAt('/')
for (const s of SECTIONS) {
for (const sub of s.subs ?? [null]) {
writeRoute({ section: s.id, sub }, true)
expect(readRoute(SECTIONS)).toEqual({ section: s.id, sub })
}
}
})
})
/*
The name of the `<meta>` is written by the build and read by the application, and
those are two different files. The cases above cannot see that joint because they
mount the meta under the same name they then read it by: they always agree with
themselves.
And that joint really did break. On renaming `ruta` to `route`, `vite.config.ts`
started emitting `<meta name="route">` while `app/base.ts` kept looking for
`meta[name="ruta"]`, inside a string, which is exactly where a rename does not
reach. The result: the root fell back to the whole `pathname` and from
`/dhcp/scopes/` the console asked for `/dhcp/scopes/api/status`. A 404 on 31 of
the 32 routes, with typecheck, lint and 821 tests all green.
This case reads both files and checks they say the same name.
*/
describe('the `<meta>` name is the same on both sides of the build', () => {
it('vite.config.ts writes the one app/base.ts reads', async () => {
// `?raw` is Vite's own: it needs no extra dependency, and a dependency
// would show up in the diff of the pull request.
const config = (await import('../../vite.config.ts?raw')).default
const reader = (await import('./base.ts?raw')).default
const written = /<meta name="([^"]+)" content=/.exec(config)?.[1]
const read = /meta\[name="([^"]+)"\]/.exec(reader)?.[1]
expect(written, 'vite.config.ts no longer emits a route meta').toBeTruthy()
expect(read, 'app/base.ts no longer reads a route meta').toBeTruthy()
expect(read).toBe(written)
})
})
+127
View File
@@ -0,0 +1,127 @@
import type { Section } from './sections'
/*
The console's route, in the real address bar.
Upstream has none: its tabs are Bootstrap 3's, which call `preventDefault()` and
never touch the URL, so reloading always returns to the Dashboard. This is
therefore an ADDITION of ours and not recovered parity. It is stated here because
the project's governing constraint is "design only, zero functionality" and whoever
reads the diff should know which of the two they are looking at.
**And it needs no server change.** `www/` is served with `UseDefaultFiles()`, which
resolves `/settings/logging/` to `/settings/logging/index.html` and redirects the
slash-less version with a 301. The build (see `vite.config.ts`) emits one folder
per route, so the URL genuinely exists: it can be copied, bookmarked and reloaded.
**The application root cannot be assumed.** The server honours
`X-Forwarded-Prefix` by mounting a `PathBase` (`DnsWebService.cs:1943-1945`), so the
console may be hanging off `/dns/` without knowing it. Each copy carries its own
route in `<meta name="route">`, and the root comes from subtracting it from the
`pathname`. That is the piece that makes all of this work behind a proxy.
*/
export { toSlug } from './slug'
import { toSlug } from './slug'
export { forgetRoot, appRoot } from './base'
import { appRoot } from './base'
import { LEGACY_ROUTES } from './static-routes'
export interface Route {
section: string
sub: string | null
}
/**
* Where the console hangs from, ending in `/`.
*
* On the front page it is the `pathname` itself. At `/dns/settings/logging/` it is
* `/dns/`, and that is known because the document declares its route to be
* `settings/logging`.
*/
/** What the address bar says, resolved against the visible sections. */
export function readRoute(sections: Section[]): Route | null {
const base = appRoot()
const trail = window.location.pathname
if (!trail.startsWith(base)) return null
const [sectionId, slugSub] = trail.slice(base.length).split('/').filter(Boolean)
if (sectionId == null) return null
const section = sections.find((s) => s.id === sectionId)
if (section == null) return null
// A sub that does not exist does not invalidate the section: it falls to the first.
const sub = slugSub == null ? null : (section.subs?.find((t) => toSlug(t) === slugSub) ?? null)
return { section: section.id, sub }
}
export function toTrail({ section, sub }: Route): string {
return appRoot() + (sub == null ? `${section}/` : `${section}/${toSlug(sub)}/`)
}
/**
* Writes the route. `replaceEntry` for the boot normalisation (which is not a
* navigation and must not leave a history entry) and pushing for what the user
* does, so the back button walks the sections instead of taking them out of the
* console.
*
* The `search` is kept and the `hash` is NOT: the hash is only used by the SSO
* return (`session/boot.ts`), which has already read it and wants it gone from the
* bar.
*/
export function writeRoute(route: Route, replaceEntry = false): void {
const blank = toTrail(route) + window.location.search
if (window.location.pathname + window.location.search === blank) return
if (replaceEntry) window.history.replaceState(null, '', blank)
else window.history.pushState(null, '', blank)
}
/*
`/allowed/` and `/blocked/` are Blocking's Rules tab now. The bar is rewritten to
`blocking/rules/?rule=…` BEFORE the route is read, with `replaceState` so the back
button does not walk into the old address. The root comes from `appRoot()`, which
the legacy folder's own `<meta name="route">` lets it compute behind a prefix.
*/
export function translateLegacyRoute(): boolean {
const base = appRoot()
const trail = window.location.pathname
if (!trail.startsWith(base)) return false
const parts = trail.slice(base.length).split('/').filter(Boolean)
const legacy = parts.length === 1 ? LEGACY_ROUTES[parts[0]] : undefined
if (legacy == null) return false
const params = new URLSearchParams(window.location.search)
params.set('rule', legacy.rule)
window.history.replaceState(null, '', `${base}${legacy.section}/${toSlug(legacy.sub)}/?${params.toString()}`)
return true
}
/**
* A click the browser should handle itself: another button, or with a modifier
* (open in a new tab, in a window, download), or one something else already took.
* Intercepting them would turn a real link into a button in disguise. The sidebar,
* the sub-tabs and `ui/RouteLink` all ask this same question.
*/
export function plainClick(e: {
button: number
metaKey: boolean
ctrlKey: boolean
shiftKey: boolean
altKey: boolean
defaultPrevented?: boolean
}): boolean {
return e.defaultPrevented !== true && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey
}
/**
* Goes to another section from inside a screen, without reloading the console.
*
* The route is pushed (it is something the user did, so the back button returns)
* and then announced with the same `popstate` the back button fires. The Shell
* already follows that event (it reads the bar and moves to what it says), so a
* screen needs no handle on the Shell's state to send the user elsewhere.
*/
export function navigateTo(route: Route): void {
writeRoute(route)
window.dispatchEvent(new PopStateEvent('popstate'))
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { visibleSections, type Section } from './sections'
const P = (canView: boolean) => ({ canView, canModify: false, canDelete: false })
const LIST: Section[] = [
{ id: 'one', label: 'One', permission: 'One' },
{ id: 'any', label: 'Any', permission: ['A', 'B'] },
{ id: 'free', label: 'Free', permission: null },
]
describe('visibleSections', () => {
it('a list of permissions means any of them', () => {
const ids = (p: Parameters<typeof visibleSections>[0]) => visibleSections(p, LIST).map((s) => s.id)
expect(ids({ One: P(true), A: P(false), B: P(true) })).toEqual(['one', 'any', 'free'])
expect(ids({ One: P(false), A: P(false), B: P(false) })).toEqual(['free'])
expect(ids(undefined)).toEqual(['one', 'any', 'free'])
})
})
+54
View File
@@ -0,0 +1,54 @@
/*
The console's 11 sections, in upstream's order, with one that is OURS: Blocking
takes the place of upstream's Allowed and Blocked, and gathers them with the block
lists into three tabs (CONVENTIONS.md, "Deliberate deviations from upstream
behaviour", item 2). Their old addresses still land: see `LEGACY_ROUTES` in
`static-routes.ts`.
`permission` is the key inside `sessionData.info.permissions`, and it does NOT
always match the label: the "DNS Client" tab is governed by `DnsClient` and "DHCP"
by `DhcpServer`. `About` has no permission: it is always visible.
Each section is hidden if its `canView` is false (main.js:119-250).
*/
export interface Section {
id: string
label: string
/** The key inside `permissions`, or a LIST meaning "any of these" (Blocking:
* visible with Blocked OR Allowed). */
permission: string | readonly string[] | null
/** Sub-sections, with upstream's literal labels. They are only shown when their
* section is active, exactly as the sub-tabs are today. */
subs?: string[]
}
export const SECTIONS: Section[] = [
{ id: 'dashboard', label: 'Dashboard', permission: 'Dashboard' },
{ id: 'zones', label: 'Zones', permission: 'Zones' },
{ id: 'cache', label: 'Cache', permission: 'Cache' },
{ id: 'blocking', label: 'Blocking', permission: ['Blocked', 'Allowed'],
subs: ['Overview', 'Rules', 'Lists'] },
{ id: 'apps', label: 'Apps', permission: 'Apps' },
{ id: 'dnsclient', label: 'DNS Client', permission: 'DnsClient' },
{ id: 'settings', label: 'Settings', permission: 'Settings',
subs: ['General','Web Service','Optional Protocols','TSIG','Recursion','Cache','Blocking','Proxy & Forwarders','Logging'] },
{ id: 'dhcp', label: 'DHCP', permission: 'DhcpServer', subs: ['Leases','Scopes'] },
{ id: 'admin', label: 'Administration', permission: 'Administration',
subs: ['Sessions','Users','Groups','Permissions','SSO','LDAP','Cluster'] },
{ id: 'logs', label: 'Logs', permission: 'Logs', subs: ['View Logs','Query Logs'] },
{ id: 'about', label: 'About', permission: null },
]
export interface Permission { canView: boolean; canModify: boolean; canDelete: boolean }
export function visibleSections(
permissions: Record<string, Permission> | undefined,
list: Section[] = SECTIONS,
): Section[] {
if (!permissions) return list
return list.filter((s) => {
if (s.permission == null) return true
const keys = typeof s.permission === 'string' ? [s.permission] : s.permission
return keys.some((k) => permissions[k]?.canView !== false)
})
}
+15
View File
@@ -0,0 +1,15 @@
/*
`Proxy & Forwarders` → `proxy-forwarders`.
It lives on its own and not inside `route.ts` because both sides need it: the
browser to read the address bar, and `vite.config` to know which folders the build
has to emit. `route.ts` touches `window` and `document`, and the config's TypeScript
project has no DOM: a string function cannot drag that dependency along.
*/
export function toSlug(label: string): string {
return label
.toLowerCase()
.replace(/&/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
+35
View File
@@ -0,0 +1,35 @@
/* The `.js` extensions are deliberate: this file is also compiled by the
`vite.config` TypeScript project, which uses `nodenext` resolution and requires
them. The application's project resolves them just the same. */
import { SECTIONS } from './sections.js'
import { toSlug } from './slug.js'
/*
The routes the build has to materialise as folders.
The console is served as static files: `UseDefaultFiles()` maps `/zones/` to
`/zones/index.html` and redirects `/zones` with a 301, so that file merely existing
is enough for `dns.example.net/settings/logging/` to work and survive an F5. It is
the same technique any static site generator uses to publish a site without
configuring the server.
It lives in `src/` and not in `vite.config` because the list comes from `SECTIONS`:
if a new sub-section appears tomorrow, its folder is generated by itself.
*/
/*
The routes that no longer have a section of their own: `/allowed/` and `/blocked/`
became the Rules tab of Blocking (CONVENTIONS.md, "Deliberate deviations from
upstream behaviour", item 2). Their
folders are still emitted so a bookmark keeps working; `route.ts` translates them
before reading the route.
*/
export const LEGACY_ROUTES: Record<string, { section: string; sub: string; rule: 'allowed' | 'blocked' }> = {
allowed: { section: 'blocking', sub: 'Rules', rule: 'allowed' },
blocked: { section: 'blocking', sub: 'Rules', rule: 'blocked' },
}
export const STATIC_ROUTES: string[] = [
...SECTIONS.flatMap((s) => [s.id, ...(s.subs ?? []).map((t) => `${s.id}/${toSlug(t)}`)]),
...Object.keys(LEGACY_ROUTES),
]