Files
technitium-console/src/api/logs.ts
T
DenozordecandClaude Opus 5.5 48b5d9fb15 Hub stage 3: the console in hub mode
With info.hub the node selector reads Server / All servers, the default-credentials
password prompt and the account actions the hub cannot serve are gone, the header
status reports the servers an aggregate could not reach (hubWarnings, kept until the
next aggregate), and merged Query Logs show a Server column. Without the flag
nothing changes. The hub sends hubWarnings on every aggregate answer, empty when all
servers answered, and never on a single-node one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:18:58 +07:00

258 lines
9.1 KiB
TypeScript

import { apiRequest, type ApiOutcome } from './client'
import { openDownload } from './user'
import { urlApi } from '../app/base'
/*
The `logs` family: six endpoints, all of them in upstream's `logs.js`.
Five things to know before touching this screen:
1. **`logs/download` does NOT return JSON.** On success it answers `text/plain`
and the whole file (`Content-Disposition: attachment`); on error it answers
`application/json` and the usual envelope. Checked against a v15.4 instance.
That is why it can NOT go through `apiRequest`, which does `res.json()` and
would turn any log into a network failure. Upstream asks for it with
`isTextResponse: true` and, if what arrives carries `status`, draws it
formatted inside the viewer's own `<pre>` (logs.js:170-172): the error reads
as if it were the file's content. That is replicated.
2. **The viewer asks only for the first 2 MB** (`limit=2`, which the server
multiplies by 1024*1024 in `WebServiceLogsApi.cs:100`). The "Download"
button asks for the WHOLE file: it goes without `limit`.
3. **`logs/download` and `logs/export` are downloads with a single-use token**,
via `window.open`, because the browser has to receive the
`Content-Disposition`. They go through `openDownload` in `api/user.ts`.
4. **`logs/list` returns the name WITHOUT extension** (`fileName` comes from
`Path.GetFileNameWithoutExtension`) and the size already formatted as a
string (`"20.48 KB"`). The delete is asked for with that same name, but the
parameter is called `log`, not `fileName`: the two endpoints do not share a
parameter name. Mixing them up gives a 500.
5. **`logs/query` validates on the server with `Enum.Parse`**: a `qtype` that
is not a real type answers `Requested value 'ZZZ' was not found.`. There is
no prior client-side validation for that field, and none is added.
Permissions (`WebServiceLogsApi.cs`): `list`, `download`, `query` and `export`
ask for `Logs.View`; `delete` and `deleteAll` ask for `Logs.Delete`.
*/
export interface LogFile {
/** Without extension: the server already strips it. */
fileName: string
/** Already formatted by the server, e.g. `"20.48 KB"`. */
size: string
}
/** A row of `logs/query`. `responseRtt` is OMITTED when there is no measurement,
* and `qname`, `qtype`, `qclass` and `answer` arrive as an explicit `null` when
* the entry has no question or no answer (`WebServiceLogsApi.cs:221-234`). */
export interface QueryLogEntry {
rowNumber: number
timestamp: string
clientIpAddress: string
protocol: string
responseType: string
responseRtt?: number
rcode: string
qname: string | null
qtype: string | null
qclass: string | null
answer: string | null
/** Only from the hub: the server this merged row came from. */
node?: string
}
export interface QueryLogPage {
pageNumber: number
totalPages: number
totalEntries: number
entries: QueryLogEntry[]
}
/** The query's filters, in the order upstream builds them into the URL. */
export interface QueryLogsParams {
name: string
classPath: string
pageNumber: string
entriesPerPage: string
descendingOrder: string
start: string
end: string
clientIpAddress: string
protocol: string
responseType: string
rcode: string
qname: string
qtype: string
qclass: string
node?: string
}
/*
Returns the whole outcome, not a list.
It used to return `[]` when the server failed, and that looked prudent ("the
screen does not blow up if the request falls over". It was the opposite: an empty
list and a failure draw the same, so the screen said "No Log File Was Found" when
what had happened was that the call never arrived. That is worse than an error,
because nobody suspects a response that looks normal.
By returning the `ApiOutcome` (as the list screens already did, and those did
warn), the type forces the two apart, and the message the server sent is kept as
well, which is what upstream shows.
*/
/** `logs/list` (logs.js:112). */
export async function listLogFiles(
token: string | null,
node = '',
): Promise<ApiOutcome<LogFile[]>> {
const outcome = await apiRequest<{ response: { logFiles: LogFile[] } }>('logs/list', {
token,
body: { node },
})
return outcome.kind === 'ok'
? { kind: 'ok', data: outcome.data.response?.logFiles ?? [] }
: outcome
}
/*
`logs/download` for the VIEWER (logs.js:161). It does not go through
`apiRequest` because the good response is not JSON; see point 1 of the header.
What is drawn when the server answers an error is replicated too: the formatted
JSON, inside the viewer itself.
*/
export async function downloadLogText(
token: string | null,
fileName: string,
node = '',
limit = '2',
): Promise<string | null> {
const query = new URLSearchParams({ fileName, limit, node })
const headers: Record<string, string> = {}
if (token) headers.Authorization = `Bearer ${token}`
let text: string
try {
const res = await fetch(urlApi(`api/logs/download?${query.toString()}`), { headers })
text = await res.text()
} catch {
return null
}
// logs.js:170: `if (response.status != null) response = JSON.stringify(...)`.
try {
const parsed: unknown = JSON.parse(text)
if (parsed !== null && typeof parsed === 'object' && 'status' in parsed) {
return JSON.stringify(parsed, null, 2)
}
} catch {
/* It was not JSON: it is the log. */
}
return text
}
/** The viewer's "Download" button (logs.js:186): the WHOLE file, without
* `limit`, with a single-use token and in a new tab.
*
* It carries `ts` (the cache-buster) because upstream adds it here
* (`"&ts=" + (new Date().getTime())`). `logs/export` does NOT carry it. */
export function openLogDownload(
token: string | null,
fileName: string,
node = '',
): Promise<{ ok: boolean; url?: string }> {
return openDownload(token, 'logs/download', { fileName, node }, { ts: true })
}
/** `logs/delete` (logs.js:212). Careful: the parameter is called `log`. */
export function deleteLog(token: string | null, log: string, node = ''): Promise<ApiOutcome> {
return apiRequest('logs/delete', { token, body: { log, node } })
}
export function deleteAllLogs(token: string | null, node = ''): Promise<ApiOutcome> {
return apiRequest('logs/deleteAll', { token, body: { node } })
}
/** `logs/query` (logs.js:436). Returns the outcome unwrapped so the screen can
* tell a server error apart from an empty page. */
export async function queryLogs(
token: string | null,
params: QueryLogsParams,
): Promise<ApiOutcome<{ response: QueryLogPage }>> {
const body: Record<string, string> = { ...params, node: params.node ?? '' }
return apiRequest<{ response: QueryLogPage }>('logs/query', { token, body })
}
/** `logs/export` (logs.js:678). The same filters as `query` MINUS the three
* paging ones: `pageNumber`, `entriesPerPage` and `descendingOrder` do not
* travel. And without `ts`: of the console's six downloads, only the settings
* backup and `logs/download` carry it (verified in main.js:3100, logs.js:196,
* zone.js:1322, other-zones.js:554 and 623). */
export function exportLogsCsv(
token: string | null,
params: QueryLogsParams,
): Promise<{ ok: boolean; url?: string }> {
const { name, classPath, start, end, clientIpAddress, protocol, responseType, rcode, qname, qtype, qclass } = params
return openDownload(token, 'logs/export', {
name,
classPath,
start,
end,
clientIpAddress,
protocol,
responseType,
rcode,
qname,
qtype,
qclass,
node: params.node ?? '',
})
}
/*
The options of the form's dropdowns, with the labels and the literal values of
index.html:3300-3399. The first option of the four response filters is the empty
one and comes selected.
*/
export const ENTRIES_PER_PAGE = ['10', '25', '50', '100', '250', '500'] as const
export const PROTOCOLS: { value: string; label: string }[] = [
{ value: '', label: '' },
{ value: 'Udp', label: 'UDP' },
{ value: 'Tcp', label: 'TCP' },
{ value: 'Tls', label: 'TLS' },
{ value: 'Https', label: 'HTTPS' },
{ value: 'Quic', label: 'QUIC' },
{ value: 'UdpProxy', label: 'UDP Proxy' },
{ value: 'TcpProxy', label: 'TCP Proxy' },
]
export const RESPONSE_TYPES: { value: string; label: string }[] = [
{ value: '', label: '' },
{ value: 'Authoritative', label: 'Authoritative' },
{ value: 'Recursive', label: 'Recursive' },
{ value: 'Cached', label: 'Cached' },
{ value: 'Blocked', label: 'Blocked' },
{ value: 'UpstreamBlocked', label: 'UpstreamBlocked' },
{ value: 'UpstreamBlockedCached', label: 'UpstreamBlockedCached' },
]
export const RCODES: { value: string; label: string }[] = [
{ value: '', label: '' },
{ value: 'NoError', label: 'No Error' },
{ value: 'FormatError', label: 'Format Error' },
{ value: 'ServerFailure', label: 'Server Failure' },
{ value: 'NxDomain', label: 'NX Domain' },
{ value: 'NotImplemented', label: 'Not Implemented' },
{ value: 'Refused', label: 'Refused' },
{ value: 'YXDomain', label: 'YX Domain' },
{ value: 'YXRRSet', label: 'YX RRSet' },
{ value: 'NXRRSet', label: 'NX RRSet' },
{ value: 'NotAuth', label: 'Not Auth' },
{ value: 'NotZone', label: 'Not Zone' },
]
export const QCLASSES = ['', 'IN', 'CS', 'CH', 'HS', 'NONE', 'ANY'] as const