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:
commit
f3531c64f9
680 files changed
+139836
No files matched your search
@@ -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; }
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 : ''
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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, '')
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
Reference in new issue
Block a user