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 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:06:06 +07:00
commit f3531c64f9
680 files changed
+139836

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
import { urlApi } from '../app/base'
/*
The console's HTTP client. A replica of the old console's `HTTPRequest` helper
(upstream/master:DnsServerCore/www/js/common.js:28).
Three things that are the server's contract and not our preferences:
1. Paths go RELATIVE and without a leading slash. The server honours
X-Forwarded-Prefix and mounts a PathBase (DnsWebService.cs:1943-1945), so a
leading slash breaks any install behind a proxy with a prefix.
2. The response is NOT unwrapped. `user/login` and `user/session/get` return
the session FLAT; every other endpoint wraps it in `response`. Verified
against a v15.4 instance. Just like upstream, the JSON is handed over as it
came and the caller decides.
3. The four values of `status` are `ok`, `2fa-required`, `invalid-token` and
`error`. Verified in DnsWebService.cs:2475-2543.
*/
export type ApiOutcome<T = unknown> =
| { kind: 'ok'; data: T }
| { kind: 'error'; message: string }
| { kind: 'invalid-token' }
| { kind: 'two-factor-required' }
export interface ApiOptions {
method?: 'GET' | 'POST'
body?: Record<string, string>
token?: string | null
/*
Which cluster node answers this request.
It is not per-endpoint: the server reads `node` centrally in
`DnsWebService.cs:2355` and proxies the whole request to that node, so any call
can carry it. The empty string means "this one" and is not sent.
`cluster`, the aggregate, IS sent, as upstream sends it (main.js:2622, 2935):
the proxy skips it (`DnsWebService.cs:2356`), and `dashboard/stats/get` and
`dashboard/stats/getTop` read it themselves to add every node up
(`WebServiceDashboardApi.cs:281-282, 801-802`). Dropping it, as this client did
until v1.2.1, made the Dashboard's "Cluster" show the local node alone.
Upstream offers the choice on eight screens and remembers it per screen
(`ui/ClusterNodeSelect`).
*/
node?: string | null
/*
Multipart upload. The console has five: install and update app, import zone,
import records and restore settings (apps.js:348,392 · zone.js:1273,3039 ·
main.js:3169). They go with FormData, not with an encoded form, so
Content-Type must NOT be set by hand: the browser sets it with its boundary.
The fields of `body` travel inside the FormData too.
*/
file?: { field: string; file: File }
/** Alternative to `file` when the caller already built the FormData. */
form?: FormData
/*
Plain-text body. One single screen asks for it: importing a zone by pasting
the file into a textarea sends the raw text with `Content-Type: text/plain`
instead of multipart (zone.js:1281). The server tells them apart by that type.
*/
text?: string
}
interface Envelope {
status?: string
errorMessage?: string
}
/*
What to do when the server says the session is no longer valid.
Upstream ALWAYS ends the session: `invalid-token` calls `showPageLogin()` (which
clears the token and shows the login) in the sixty-four calls that declare it,
and in the ones that do not, it falls through to the `window.location = "/"` of
`common.js:147`.
Here nobody did. Every screen showed "Invalid token or session expired." and
stayed where it was, with the console apparently usable and every action failing
one after another; to get back in you had to know that a reload was due. Beyond
being awkward, this is an administration console: it must not stay standing with
a session the server has already rejected.
It is solved in a single place, here, and not in the thirty screens, because the
rule is one: `SessionProvider` registers it, since it is the one that holds the
session.
*/
let onExpired: ((token?: string | null) => void) | null = null
export function onSessionExpired(fn: ((token?: string | null) => void) | null): void {
onExpired = fn
}
/*
The envelope read in ONE place. `apiRequest` uses it, and so do the few calls that
cannot go through it because their good answer is not JSON (`allowed/export`,
`blocked/export`): when those fail they answer this same envelope, and an
`invalid-token` there has to end the session like anywhere else.
*/
export function envelopeOutcome<T>(payload: Envelope, token?: string | null): ApiOutcome<T> {
switch (payload.status) {
case 'ok':
return { kind: 'ok', data: payload as T }
case 'invalid-token':
onExpired?.(token)
return { kind: 'invalid-token' }
case '2fa-required':
return { kind: 'two-factor-required' }
default:
return { kind: 'error', message: payload.errorMessage ?? 'Unknown error.' }
}
}
export async function apiRequest<T = unknown>(
path: string,
opts: ApiOptions = {},
): Promise<ApiOutcome<T>> {
const { method = 'GET', body, token } = opts
const headers: Record<string, string> = {}
if (token) headers.Authorization = `Bearer ${token}`
let url = urlApi(`api/${path}`)
if (opts.node) {
url += (url.includes('?') ? '&' : '?') + 'node=' + encodeURIComponent(opts.node)
}
const init: RequestInit & { headers: Record<string, string> } = { method, headers }
if (opts.form) {
init.method = 'POST'
init.body = opts.form
} else if (opts.text != null) {
init.method = 'POST'
headers['Content-Type'] = 'text/plain'
init.body = opts.text
} else if (opts.file) {
const fd = new FormData()
for (const [k, v] of Object.entries(body ?? {})) fd.append(k, v)
fd.append(opts.file.field, opts.file.file)
init.method = 'POST'
init.body = fd
// No Content-Type by hand: the browser adds the boundary.
} else if (body) {
const encoded = new URLSearchParams(body).toString()
if (method === 'POST') {
headers['Content-Type'] = 'application/x-www-form-urlencoded'
init.body = encoded
} else {
url += (url.includes('?') ? '&' : '?') + encoded
}
}
/*
common.js:182-196. A request that never gets an answer is jQuery's
`textStatus === "error"` with an empty `errorThrown`, and upstream says so with
its own sentence; any other failure (an answer that is not JSON) is shown as
jQuery's `textStatus - errorThrown`. The sentence had been replaced with one of
this console's own until 2026-09-30.
*/
let res: Response
try {
res = await fetch(url, init)
} catch {
return { kind: 'error', message: 'Unable to connect to the server. Please try again.' }
}
let payload: Envelope
try {
payload = (await res.json()) as Envelope
} catch (e) {
return { kind: 'error', message: `parsererror - ${String(e)}` }
}
return envelopeOutcome<T>(payload, token)
}