feat: admin metrics page + metrics endpoint

scarlett
NGPixel 4 days ago
parent e8b6fcb3fc
commit fb184f6375
No known key found for this signature in database

@ -324,8 +324,8 @@ kind a name belongs to decides how it may be enforced, so it is the first thing
any permission you touch.
**Global permissions** are held site-wide, bound to no path: `access:admin`, `read:users`,
`manage:users`, `read:groups`, `manage:groups`, `read:audit`, `manage:navigation`, `manage:theme`,
`manage:sites`, `manage:system`. That is the list as it stands — the one offered by the group editor
`manage:users`, `read:groups`, `manage:groups`, `read:audit`, `read:metrics`,
`manage:navigation`, `manage:theme`, `manage:sites`, `manage:system`. That is the list as it stands — the one offered by the group editor
(`GroupEditOverlay.vue`). They live on a group's `permissions` column, are flattened onto
`req.session.permissions` at login (`models/users.ts` → `updateSession`), and are what the per-route
`config.permissions` hook checks. `manage:system` bypasses every check everywhere.
@ -779,6 +779,38 @@ because shortening it destroys evidence and that is not the same authority as lo
`retentionDays()` reads such a value AS 30 rather than honouring it — the second check is what
closes the config-file and direct-database routes round the first.
### Metrics
A Prometheus exposition, served by `controllers/metrics.ts` and configured by `models/metrics.ts`
the `metrics` settings blob, the admin area's **Metrics** screen, `GET`/`PUT /system/metrics`.
Everything about it is read per request through the `WIKI` global, so a change applies at once and on
every instance; nothing here is captured at boot.
- **A hook, not a route**, because the path is a setting and a route table is fixed at boot. It does
nothing unless the endpoint is enabled AND the path matches, which is exactly what leaves a page at
`/metrics` serving normally while metrics are off. Turned on, it shadows that page — the one thing
the endpoint is *allowed* to shadow. `validate` refuses a path whose first segment starts with `_`
(the server's and the frontend router's namespace) or that names a `RESERVED_ROOT_FILES` entry,
because breaking those breaks the instance from a screen that cannot then be reached to undo it.
- **It is registered before the SEO hook** in `index.ts`, and that ordering is load-bearing: a metrics
path looks like a page path, so the redirects there would send a scrape to the site's locale prefix
or strip a page extension off it. It is registered *after* the session and API key hooks, whose work
it reads.
- **Anonymous access is per address class** — local, private, external (`helpers/network.ts`,
`net.BlockList`). An address in a class the operator opened is answered with no credentials at all;
every other address must hold `read:metrics`, as a bearer API key or as a signed-in session. Which
is why the bearer hook in `index.ts` lets the metrics path through as well as `/_api/`: one place
verifies a token. Anything that is not an IP address is `external`, so the unknown case is the
strict one. What an address *means* depends on `security.trustProxy` — with it off, a wiki behind a
proxy sees the proxy for every request, and the admin screen says so.
- **Two registries, for two lifetimes.** `collectDefaultMetrics` attaches probes to a registry for the
life of the process, so the runtime registry is built once, lazily — a wiki that never turns metrics
on carries no probes. The wiki gauges are database counts, so they are built and thrown away per
scrape — and are off by default, since a scrape of them costs about a dozen queries. The exposition is line-based, so the two outputs simply concatenate.
- **Runtime metrics are this instance's; wiki metrics are the cluster's.** In an HA set a scrape lands
on whichever instance answered, which is what `wiki_info`'s `instance` label and
`wiki_start_time_seconds` are for.
### GraphQL is being removed
An earlier iteration of 3.x used GraphQL/Apollo. **All of it is deprecated** — there is no GraphQL

@ -18,6 +18,7 @@ async function routes(app: FastifyInstance) {
await import('./schemas/icon.ts').then((m) => m.registerSchemas(app))
await import('./schemas/locale.ts').then((m) => m.registerSchemas(app))
await import('./schemas/mail.ts').then((m) => m.registerSchemas(app))
await import('./schemas/metrics.ts').then((m) => m.registerSchemas(app))
await import('./schemas/page.ts').then((m) => m.registerSchemas(app))
await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app))
await import('./schemas/security.ts').then((m) => m.registerSchemas(app))

@ -0,0 +1,48 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* METRICS CONFIG - Used both ways: as the response, and as a partial update body
*/
app.addSchema({
$id: 'MetricsConfig',
type: 'object',
properties: {
isEnabled: {
type: 'boolean',
description:
'Whether the endpoint is served at all. While it is off nothing is registered at the path, so a wiki page there is served normally; turning it on is what makes the endpoint take that path over.'
},
path: {
type: 'string',
maxLength: 512,
description:
"The URL path the exposition is served at, e.g. `/metrics`. Normalized to a single leading slash and no trailing one. Cannot be a segment starting with `_` (the wiki's own namespace) or a reserved root file such as `robots.txt`."
},
allowAnonymousLocal: {
type: 'boolean',
description: 'Allow scraping without credentials from this machine — `127.0.0.0/8`, `::1`.'
},
allowAnonymousPrivate: {
type: 'boolean',
description:
'Allow scraping without credentials from a private network — the RFC 1918 ranges, IPv6 unique local addresses, and link-local addresses.'
},
allowAnonymousExternal: {
type: 'boolean',
description:
'Allow scraping without credentials from any other address, i.e. serve the metrics publicly.'
},
includeRuntime: {
type: 'boolean',
description:
'The Node.js process this scrape reached: CPU, memory, garbage collection, event loop lag, handles. Per instance, not cluster-wide.'
},
includeWiki: {
type: 'boolean',
description:
'The wiki itself: page, user, group, site, tag and asset totals, the job queue, pending suggested edits and scheduler health. Read from the database, so cluster-wide — and off by default, since each scrape of it runs about a dozen queries.'
}
}
})
}

@ -780,7 +780,7 @@ async function routes(app: FastifyInstance) {
)
/**
* GET METRICS ENDPOINT STATE
* GET METRICS CONFIGURATION
*/
app.get(
'/metrics',
@ -789,64 +789,117 @@ async function routes(app: FastifyInstance) {
permissions: ['manage:system']
},
schema: {
summary: 'Get the metrics endpoint state',
summary: 'Get the metrics endpoint configuration',
description:
'Whether the Prometheus metrics endpoint is turned on. The endpoint itself is not implemented yet — see the description of the PUT counterpart.',
'Whether the Prometheus metrics endpoint is turned on, the path it answers at, and who may scrape it without credentials.',
tags: ['System'],
response: {
200: { $ref: 'MetricsConfig#' }
}
}
},
async () => {
return WIKI.models.metrics.getConfig()
}
)
/**
* UPDATE METRICS CONFIGURATION
*/
app.put<{ Body: Record<string, any> }>(
'/metrics',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the metrics endpoint configuration',
description:
'Accepts any subset of the fields, and applies at once on every instance — nothing here is read at boot. While the endpoint is enabled it takes its path over from the page tree, so a wiki page at that path becomes unreachable until it is turned off again.',
tags: ['System'],
body: { $ref: 'MetricsConfig#' },
response: {
200: {
description: 'Metrics endpoint state',
description: 'Metrics endpoint configuration updated successfully',
type: 'object',
properties: {
isEnabled: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async () => {
return { isEnabled: WIKI.config.metrics.isEnabled === true }
async (req, reply) => {
const patch = WIKI.models.metrics.pickFields(req.body)
if (Object.keys(patch).length < 1) {
return reply.badRequest('No valid metrics setting was provided.')
}
const invalid = WIKI.models.metrics.validate(patch)
if (invalid) {
return reply.badRequest(invalid)
}
if (!(await WIKI.models.metrics.updateConfig(patch))) {
return reply.internalServerError('Failed to save the metrics configuration.')
}
// -> Fields rather than values, as the other configuration routes do. `isEnabled` is the
// exception because whether the endpoint is open at all is the part that gets asked about.
await audit(req, 'admin', 'updateMetricsState', {
fields: Object.keys(patch).sort(),
...(patch.isEnabled === undefined ? {} : { isEnabled: patch.isEnabled })
})
return {
ok: true,
message: 'Metrics configuration saved successfully.'
}
}
)
/**
* SET METRICS ENDPOINT STATE
* PREVIEW THE METRICS EXPOSITION
*/
app.put<{ Body: { isEnabled: boolean } }>(
'/metrics',
app.get<{ Querystring: { includeRuntime?: boolean; includeWiki?: boolean } }>(
'/metrics/preview',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Turn the metrics endpoint on or off',
summary: 'Preview the metrics exposition',
description:
'Stores the state and nothing more, for now: the `/metrics` endpoint it governs is not implemented, and its documented `read:metrics` bearer authentication depends on API keys, which are not implemented either.',
'The Prometheus text a scrape would be answered with, for the admin area to show. Works whether or not the endpoint is enabled, and ignores the path and the anonymous-access settings entirely — this is the API talking, not the endpoint. `includeRuntime` and `includeWiki` override the stored settings so an unsaved selection can be previewed; either one left out falls back to what is stored.',
tags: ['System'],
body: {
querystring: {
type: 'object',
required: ['isEnabled'],
properties: {
isEnabled: {
includeRuntime: {
type: 'boolean'
},
includeWiki: {
type: 'boolean'
}
}
},
response: {
200: {
description: 'Metrics endpoint state updated successfully',
description: 'The exposition',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
contentType: {
type: 'string',
description: 'The `Content-Type` the endpoint answers with.'
},
isEnabled: {
type: 'boolean'
body: {
type: 'string',
description: 'The exposition itself. Empty when neither group is included.'
}
}
}
@ -854,22 +907,14 @@ async function routes(app: FastifyInstance) {
}
},
async (req, reply) => {
const previousConfig = WIKI.config.metrics
WIKI.config.metrics = { ...previousConfig, isEnabled: req.body.isEnabled }
if (!(await WIKI.configSvc.saveToDb(['metrics']))) {
WIKI.config.metrics = previousConfig
return reply.internalServerError('Failed to save the metrics endpoint state.')
}
await audit(req, 'admin', 'updateMetricsState', { isEnabled: req.body.isEnabled })
return {
ok: true,
message: req.body.isEnabled
? 'Metrics endpoint enabled successfully.'
: 'Metrics endpoint disabled successfully.',
isEnabled: req.body.isEnabled
try {
return await WIKI.models.metrics.render({
includeRuntime: req.query.includeRuntime,
includeWiki: req.query.includeWiki
})
} catch (err: any) {
WIKI.logger.warn(`Failed to collect metrics for preview: ${err.message}`)
return reply.internalServerError('Failed to collect metrics.')
}
}
)

@ -83,6 +83,23 @@ defaults:
dkimPrivateKey: ''
metrics:
isEnabled: false
# The URL path the Prometheus exposition is served at. While the endpoint is enabled it takes
# this path over from the page tree; turned off, nothing is registered and a page here is
# served normally.
path: '/metrics'
# Which addresses may scrape without credentials. Anything else has to carry an API key or a
# session holding `read:metrics`. Note that what an address means depends on
# `security.trustProxy`: with it off, a wiki behind a reverse proxy sees the proxy's address
# for every request.
allowAnonymousLocal: true
allowAnonymousPrivate: true
allowAnonymousExternal: false
# Which groups of metrics the exposition carries: the Node.js process (CPU, memory, GC, event
# loop) and the wiki itself (content totals, job queue, scheduler health). The wiki group is
# off by default because every one of its gauges is a count read fresh, so a scrape of it costs
# about a dozen database queries.
includeRuntime: true
includeWiki: false
auth:
autoLogin: false
enforce2FA: false

@ -0,0 +1,63 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import { METRICS_PERMISSION } from '../models/metrics.ts'
/**
* The Prometheus metrics endpoint.
*
* A hook rather than a route, because the path is a setting and a route table is fixed at boot. It
* is registered on the root instance in `index.ts` and does nothing at all unless metrics are turned
* on and the path matches which is what lets a page live at `/metrics` while the endpoint is off,
* and is why the check has to come BEFORE the SEO hook: that one would otherwise redirect the scrape
* to the site's locale prefix, or strip a page extension off it.
*
* Authentication is either-or. An address in a class the operator opened scrapes anonymously; every
* other address has to carry `read:metrics`, as a bearer API key (verified by the hook above this
* one, which lets the metrics path through for exactly this reason) or as the session cookie of a
* logged-in browser. `manage:system` bypasses it, as it does everywhere.
*/
export async function metricsHook(req: FastifyRequest, reply: FastifyReply) {
// -> This runs ahead of every request the wiki serves, so the disabled case — which is most wikis,
// always — costs one property read and nothing else
if (!WIKI.models.metrics.isEnabled()) {
return
}
if (!WIKI.models.metrics.matches(req.raw.url!.split('?')[0]!)) {
return
}
// -> A scrape reads; nothing here answers a POST, and saying so is more useful than a 404 at a
// path that plainly exists
if (req.method !== 'GET' && req.method !== 'HEAD') {
return reply.methodNotAllowed()
}
if (!WIKI.models.metrics.allowsAnonymous(req.ip)) {
const permissions = req.apiKey
? req.apiKey.permissions
: req.session?.authenticated
? req.session.permissions
: null
const isAllowed =
permissions?.includes(METRICS_PERMISSION) || permissions?.includes('manage:system')
if (!isAllowed) {
/*
401 rather than 403 even for a caller who is authenticated but unentitled: the answer a
scraper needs is "send a credential", and the two cases are not worth distinguishing to
somebody the endpoint is not open to anyway.
*/
return reply
.header('WWW-Authenticate', 'Bearer realm="metrics"')
.unauthorized('This endpoint requires the read:metrics permission.')
}
}
try {
const { contentType, body } = await WIKI.models.metrics.render()
// -> Never held: a scrape a minute old is worse than no scrape, and Prometheus asks again anyway
return reply.header('Cache-Control', 'no-store').type(contentType).send(body)
} catch (err: any) {
WIKI.logger.warn(`Failed to collect metrics: ${err.message}`)
return reply.internalServerError('Failed to collect metrics.')
}
}

@ -62,6 +62,17 @@ export function createDeferred<T = void>(): Deferred<T> {
}
}
/**
* Files a browser or a crawler asks for at the root by convention, rather than because the wiki has a
* page there.
*
* Kept out of the page URL rules in `index.ts` `txt` is a page extension on a default site, and
* answering `/robots.txt` with a redirect to `/robots` would be answering the wrong question. Also
* what the metrics endpoint's path is checked against, since taking one of these over would break a
* convention nothing in the admin area would explain.
*/
export const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml'])
/**
* Decode a tree path
*

@ -0,0 +1,61 @@
/**
* Where a request came from, as far as the network is concerned.
*
* The classes an operator picks from when deciding who may scrape without credentials. They are
* about reachability, not identity: `local` is this machine, `private` is a network somebody had to
* already be inside, and `external` is everything else including anything unrecognisable, since
* an address that cannot be placed must not land in the more permissive class.
*/
import net from 'node:net'
export const CLIENT_IP_CLASSES = ['local', 'private', 'external'] as const
export type ClientIpClass = (typeof CLIENT_IP_CLASSES)[number]
/*
`net.BlockList` rather than parsing addresses by hand: it takes CIDR subnets directly, and it
matches an IPv4 rule against the IPv4-mapped IPv6 form of the same address (`::ffff:127.0.0.1`),
which is what a dual-stack listener hands over for an IPv4 client. Verified, not assumed.
*/
const loopback = new net.BlockList()
loopback.addSubnet('127.0.0.0', 8)
loopback.addAddress('::1', 'ipv6')
const privateNetworks = new net.BlockList()
// -> RFC 1918
privateNetworks.addSubnet('10.0.0.0', 8)
privateNetworks.addSubnet('172.16.0.0', 12)
privateNetworks.addSubnet('192.168.0.0', 16)
// -> Link-local (RFC 3927 / RFC 4291): unroutable, so it is reached from the same segment only
privateNetworks.addSubnet('169.254.0.0', 16)
privateNetworks.addSubnet('fe80::', 10, 'ipv6')
// -> Unique local addresses (RFC 4193), the IPv6 equivalent of the RFC 1918 ranges
privateNetworks.addSubnet('fc00::', 7, 'ipv6')
/**
* Which class an address falls in.
*
* Anything that is not an IP address at all a unix socket, an empty value is `external`: this
* decides whether a request may skip authentication, so the unknown case has to be the strict one.
*
* Note that what an address MEANS depends on the `trustProxy` security setting. With it off, a wiki
* behind a reverse proxy sees every request as coming from the proxy, so a scrape from the far side
* of the internet reads as whatever the proxy's own address is.
*/
export function classifyClientIp(ip: string | null | undefined): ClientIpClass {
if (!ip) {
return 'external'
}
const type = net.isIPv6(ip) ? 'ipv6' : net.isIPv4(ip) ? 'ipv4' : null
if (!type) {
return 'external'
}
if (loopback.check(ip, type)) {
return 'local'
}
if (privateNetworks.check(ip, type)) {
return 'private'
}
return 'external'
}

@ -28,23 +28,17 @@ import ajvFormats from 'ajv-formats'
import Emittery from 'emittery'
import NodeCache from 'node-cache'
import { metricsHook } from './controllers/metrics.ts'
import collab from './core/collab.ts'
import configSvc from './core/config.ts'
import dbManager from './core/db.ts'
import logger from './core/logger.ts'
import scheduler from './core/scheduler.ts'
import { splitLocalePath, stripPageExtension } from './helpers/common.ts'
import { RESERVED_ROOT_FILES, splitLocalePath, stripPageExtension } from './helpers/common.ts'
import { corsOrigin, parseCspDirectives } from './helpers/security.ts'
const nanoid = customAlphabet('1234567890abcdef', 10)
/**
* Files a browser or a crawler asks for at the root by convention, rather than because the wiki has a
* page there. Kept out of the page URL rules below `txt` is a page extension on a default site, and
* answering `/robots.txt` with a redirect to `/robots` would be answering the wrong question.
*/
const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml'])
/**
* First path segments the SERVER itself answers every prefix registered in `initHTTPServer`.
*
@ -595,16 +589,22 @@ async function initHTTPServer() {
app.decorateRequest('apiKey', null)
app.addHook('onRequest', async (req, reply) => {
// -> Bearer tokens authenticate API calls only; everything else is cookie-authenticated. Note
// that the session is deliberately left untouched: writing to it would have @fastify/session
// persist a session row for every scraped request.
if (!req.url.startsWith('/_api/')) {
return
}
/*
Bearer tokens authenticate API calls and the metrics endpoint; everything else is
cookie-authenticated. The metrics path is here rather than verifying a key of its own, so that
there is one place a bearer token is checked it is served by a hook below, at a path that is
a setting, so it cannot declare itself part of the API by its prefix.
Note that the session is deliberately left untouched: writing to it would have
@fastify/session persist a session row for every scraped request.
*/
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
return
}
if (!req.url.startsWith('/_api/') && !WIKI.models.metrics.matches(req.url.split('?')[0]!)) {
return
}
const token = header.slice('Bearer '.length).trim()
if (!token) {
return
@ -656,6 +656,18 @@ async function initHTTPServer() {
done()
})
// ----------------------------------------
// Metrics
// ----------------------------------------
/*
Before the SEO hook on purpose: the metrics path is a plain page-looking path, so the redirects
below would send a scrape to the site's locale prefix or strip a page extension off it. And after
the session and API key hooks, whose work it reads to decide whether a scrape from outside the
addresses anonymous access was opened to is entitled to an answer.
*/
app.addHook('onRequest', metricsHook)
// ----------------------------------------
// SEO
// ----------------------------------------

@ -357,10 +357,6 @@
"admin.dashboard.versionChecking": "Checking version...",
"admin.dashboard.versionUpToDate": "Up to date!",
"admin.dashboard.versionUpdateAvailable": "Update available",
"admin.dev.flags.title": "Flags",
"admin.dev.graphiql.title": "GraphiQL",
"admin.dev.title": "Developer Tools",
"admin.dev.voyager.title": "Voyager",
"admin.editors.apiDescription": "Document your REST / GraphQL APIs.",
"admin.editors.apiName": "API Docs Editor",
"admin.editors.asciidocDescription": "Use the AsciiDoc syntax to write content. Includes real-time preview.",
@ -767,14 +763,35 @@
"admin.mcp.notImplemented": "The MCP server is not implemented yet: this page is a placeholder, and MCP cannot be enabled so far.",
"admin.mcp.subtitle": "Manage the Model Context Protocol server",
"admin.mcp.title": "MCP",
"admin.metrics.auth": "You must provide the {headerName} header with a {tokenType} token. Generate an API key with the {permission} permission and use it as the token.",
"admin.metrics.anonymousAccess": "Allow unauthenticated access for",
"admin.metrics.anonymousAccessHint": "Scrapes from these addresses are answered without any credentials. Every other address must carry the read:metrics permission.",
"admin.metrics.anonymousExternal": "External IPs",
"admin.metrics.anonymousExternalWarning": "The metrics are then readable by anyone who can reach this wiki.",
"admin.metrics.anonymousLocal": "Local",
"admin.metrics.anonymousPrivate": "Internal / Private IPs",
"admin.metrics.auth": "A scrape from an address you have not ticked must hold the {permission} permission, as an API key or as the session of a signed-in browser.",
"admin.metrics.authApiKey": "For an API key, send it in the {headerName} header as a {tokenType} token:",
"admin.metrics.configuration": "Configuration",
"admin.metrics.disabled": "Endpoint Disabled",
"admin.metrics.enabled": "Endpoint Enabled",
"admin.metrics.endpoint": "The metrics endpoint can be scraped at {endpoint}",
"admin.metrics.endpointWarning": "Note that this override any page at this path.",
"admin.metrics.loadFailed": "Failed to load the metrics endpoint state.",
"admin.metrics.notImplemented": "The endpoint itself is not available yet: this setting is saved, but nothing serves {endpoint} so far.",
"admin.metrics.refreshSuccess": "Metrics endpoint state has been refreshed.",
"admin.metrics.endpointWarning": "While the endpoint is enabled it takes that path over from any page there. Turned off, the page is served as usual.",
"admin.metrics.includeRuntime": "Runtime — CPU, memory, garbage collection and event loop of the Node.js process that answers the scrape",
"admin.metrics.includeWiki": "Wiki — page, user, group, site, tag and asset totals, the job queue, pending suggested edits and scheduler health",
"admin.metrics.includeWikiWarning": "Each scrape runs about a dozen database queries to build these, so keep the scrape interval sensible and turn this off if you only want the runtime metrics.",
"admin.metrics.included": "Included metrics",
"admin.metrics.includedHint": "What the exposition carries. At least one of the two is required.",
"admin.metrics.loadFailed": "Failed to load the metrics endpoint configuration.",
"admin.metrics.path": "Path",
"admin.metrics.pathHint": "Where the exposition is served. Cannot be a path starting with an underscore segment, which is the wiki's own.",
"admin.metrics.preview": "Preview",
"admin.metrics.previewEmpty": "Nothing to show — no group of metrics is included.",
"admin.metrics.previewHint": "What a scrape is answered with, for the options as they are ticked right now.",
"admin.metrics.previewSize": "{lines} lines, {bytes} bytes",
"admin.metrics.proxyWarning": "If this wiki sits behind a reverse proxy, every request looks like it comes from the proxy rather than from the client, so these classes will not mean what they say. Enable Trust Proxy, under Security, to fix that.",
"admin.metrics.refreshSuccess": "Metrics endpoint configuration has been refreshed.",
"admin.metrics.saveFailed": "Failed to save the metrics endpoint configuration.",
"admin.metrics.saveSuccess": "Metrics endpoint configuration saved successfully.",
"admin.metrics.subtitle": "Manage the Prometheus metrics endpoint",
"admin.metrics.title": "Metrics",
"admin.metrics.toggleStateDisabledSuccess": "Metrics endpoint disabled successfully.",
@ -1819,12 +1836,12 @@
"editor.assets.uploadAssetsDropZone": "Browse or Drop files here...",
"editor.assets.uploadFailed": "File upload failed.",
"editor.backToEditor": "Back to Editor",
"editor.blockNotEnabled": "This block is not enabled for this site, and will be removed when the page is saved. Blocks are managed in the administration area.",
"editor.blockContent.drawioLoading": "Loading the draw.io editor...",
"editor.blockContent.drawioTitle": "Draw.io editor",
"editor.blockContent.drawioUnreachable": "The draw.io editor could not be loaded. Check that this address is reachable from your browser.",
"editor.blockContent.title": "Edit Block Content",
"editor.blockContent.unknownEditor": "This block asks to be edited with something this wiki does not have.",
"editor.blockNotEnabled": "This block is not enabled for this site, and will be removed when the page is saved. Blocks are managed in the administration area.",
"editor.blockParams.title": "Edit Block Parameters - {name}",
"editor.blockPicker.blockUnavailable": "This block is not available on this site. Blocks are managed in the administration area.",
"editor.blockPicker.insert": "Insert Block",
@ -2216,11 +2233,11 @@
"fileman.oggFileType": "OGG Audio File",
"fileman.otfFileType": "OpenType Font File",
"fileman.pdfFileType": "PDF Document",
"fileman.pngFileType": "PNG Image",
"fileman.pptxFileType": "Microsoft Powerpoint Presentation",
"fileman.previewActualSize": "Actual Size",
"fileman.previewFailed": "This image could not be loaded.",
"fileman.previewFitToScreen": "Fit to Screen",
"fileman.pngFileType": "PNG Image",
"fileman.pptxFileType": "Microsoft Powerpoint Presentation",
"fileman.psdFileType": "Adobe Photoshop Document",
"fileman.rarFileType": "RAR Archive",
"fileman.redirectPageType": "Redirection",

@ -12,6 +12,7 @@ import { icons } from './icons.ts'
import { jobs } from './jobs.ts'
import { locales } from './locales.ts'
import { mail } from './mail.ts'
import { metrics } from './metrics.ts'
import { navigation } from './navigation.ts'
import { pageHistory } from './pageHistory.ts'
import { pages } from './pages.ts'
@ -44,6 +45,7 @@ export default {
jobs,
locales,
mail,
metrics,
navigation,
pageHistory,
pages,

@ -0,0 +1,313 @@
import { Gauge, Registry, collectDefaultMetrics } from 'prom-client'
import { eq, sql } from 'drizzle-orm'
import { RESERVED_ROOT_FILES } from '../helpers/common.ts'
import { classifyClientIp } from '../helpers/network.ts'
import type { ClientIpClass } from '../helpers/network.ts'
import {
assets as assetsTable,
groups as groupsTable,
jobs as jobsTable,
pageEditSubmissions as submissionsTable,
pages as pagesTable,
sites as sitesTable,
tags as tagsTable,
users as usersTable
} from '../db/schema.ts'
/** Fields stored in the `metrics` settings blob. */
export const METRICS_FIELDS = [
'isEnabled',
'path',
'allowAnonymousLocal',
'allowAnonymousPrivate',
'allowAnonymousExternal',
'includeRuntime',
'includeWiki'
] as const
/** The permission a scrape needs when its address is not one anonymous access was opened to. */
export const METRICS_PERMISSION = 'read:metrics'
/**
* Which anonymous-access setting each address class is opened by.
*
* The classes come from `helpers/network.ts`; this is the only place that ties one to a setting, so
* adding a class is a matter of naming its field here.
*/
const ANONYMOUS_FIELD_BY_CLASS: Record<ClientIpClass, (typeof METRICS_FIELDS)[number]> = {
local: 'allowAnonymousLocal',
private: 'allowAnonymousPrivate',
external: 'allowAnonymousExternal'
}
/**
* The runtime registry, built once.
*
* `collectDefaultMetrics` attaches collectors to a registry permanently, and some of them (the GC
* histogram, the event loop lag probe) hold a handle open for the life of the process so it has to
* happen once, not per scrape, and not at import time either: a wiki that never turns metrics on
* should not be carrying the probes.
*/
let runtimeRegistry: Registry | null = null
function runtimeRegistryFor(): Registry {
if (!runtimeRegistry) {
runtimeRegistry = new Registry()
collectDefaultMetrics({ register: runtimeRegistry })
}
return runtimeRegistry
}
/**
* Metrics model
*
* The Prometheus endpoint: the `metrics` settings blob the admin area edits, the decision about who
* may scrape it, and the exposition itself.
*
* Everything here is read per request through the `WIKI` global rather than captured at boot, so a
* change to the path or to who may reach it applies at once and on every instance `saveToDb`
* propagates as `reloadConfig`, whose handler REPLACES `WIKI.config`.
*/
class Metrics {
/**
* The metrics configuration as the admin area expects it
*/
getConfig(): Record<string, any> {
const metrics = WIKI.config.metrics ?? {}
const config: Record<string, any> = {}
for (const field of METRICS_FIELDS) {
config[field] = metrics[field]
}
return config
}
/**
* Keep only the fields this model owns, dropping anything else a client sends
*/
pickFields(body: Record<string, any>): Record<string, any> {
const patch: Record<string, any> = {}
for (const field of METRICS_FIELDS) {
if (body[field] !== undefined) {
patch[field] = body[field]
}
}
return patch
}
/**
* Reduce a path to the single form it is compared against a request in.
*
* Wrapping and doubled slashes go and one leading slash is put back, so `metrics/`, `/metrics` and
* `//metrics//` are the same endpoint. Casing is left alone: a URL path is case-sensitive, and an
* administrator who writes `/Metrics` means that.
*/
normalizePath(input: unknown): string {
const segments = `${input ?? ''}`.trim().split('/').filter(Boolean)
return segments.length > 0 ? `/${segments.join('/')}` : ''
}
/**
* Check a patch against the settings it will end up merged with.
*
* @returns The reason it is invalid, or null when it is fine
*/
validate(patch: Record<string, any>): string | null {
const merged = { ...this.getConfig(), ...patch }
const path = this.normalizePath(merged.path)
if (!path) {
return 'The metrics path must name at least one segment, e.g. /metrics.'
}
if (/[\s?#]/.test(path)) {
return 'The metrics path cannot contain whitespace, a query string or a fragment.'
}
const firstSegment = path.split('/')[1] ?? ''
/*
The two namespaces that are not the wiki's to give away. A leading underscore is where the
server itself mounts (`/_api`, `/_files`, ) and where the frontend router expects its own
screens; the reserved root files are what a browser or a crawler asks for by convention. The
endpoint deliberately shadows a PAGE that is the point of it but shadowing the API or the
admin area would break the instance from a screen that cannot then be reached to undo it.
*/
if (firstSegment.startsWith('_')) {
return 'The metrics path cannot start with an underscore segment — those belong to the wiki itself.'
}
if (RESERVED_ROOT_FILES.has(path.slice(1).toLowerCase())) {
return `${path} is reserved.`
}
if (!merged.includeRuntime && !merged.includeWiki) {
return 'At least one group of metrics must be included, otherwise the endpoint has nothing to serve.'
}
return null
}
/**
* Save a validated patch, normalizing the path as it goes.
*
* @returns Whether the settings were saved
*/
async updateConfig(patch: Record<string, any>): Promise<boolean> {
const normalized = { ...patch }
if (normalized.path !== undefined) {
normalized.path = this.normalizePath(normalized.path)
}
const previousConfig = WIKI.config.metrics
WIKI.config.metrics = { ...previousConfig, ...normalized }
if (!(await WIKI.configSvc.saveToDb(['metrics']))) {
WIKI.config.metrics = previousConfig
return false
}
return true
}
/** Whether the endpoint is turned on at all. */
isEnabled(): boolean {
return WIKI.config.metrics?.isEnabled === true
}
/**
* Whether this URL path is the metrics endpoint.
*
* False whenever the endpoint is off, which is what leaves a page at that path serving normally:
* nothing is registered as a route, so with metrics disabled the request carries on to the page
* tree exactly as it would have.
*
* The comparison forgives a trailing slash, since the server does elsewhere (`ignoreTrailingSlash`,
* and the redirect in the SEO hook) and a scrape configuration is written by hand.
*/
matches(urlPath: string): boolean {
if (!this.isEnabled()) {
return false
}
const configured = this.normalizePath(WIKI.config.metrics?.path)
return configured.length > 0 && this.normalizePath(urlPath) === configured
}
/**
* Whether a scrape from this address may skip authentication.
*/
allowsAnonymous(ip: string | null | undefined): boolean {
const field = ANONYMOUS_FIELD_BY_CLASS[classifyClientIp(ip)]
return WIKI.config.metrics?.[field] === true
}
/**
* The exposition, in the Prometheus text format.
*
* The two groups are separate registries rather than one: the runtime collectors are attached for
* the life of the process, while the wiki gauges are read from the database and are therefore built
* and thrown away per scrape. Their outputs concatenate because the format is line-based and each
* metric carries its own HELP and TYPE.
*
* `groups` overrides which of the two are collected, for the admin area's preview: it renders what
* the form in front of the operator says rather than what was last saved, so that ticking a box and
* looking is one step instead of two. A scrape passes nothing and gets the stored settings.
*/
async render(groups?: {
includeRuntime?: boolean
includeWiki?: boolean
}): Promise<{ contentType: string; body: string }> {
const config = WIKI.config.metrics ?? {}
const parts: string[] = []
// -> Both read as opt-in rather than opt-out: `base.yml` gives every instance both keys, so an
// absent one is not a setting to be read generously — and the wiki group defaults to off
if ((groups?.includeRuntime ?? config.includeRuntime) === true) {
parts.push(await runtimeRegistryFor().metrics())
}
if ((groups?.includeWiki ?? config.includeWiki) === true) {
parts.push(await (await this.collectWikiMetrics()).metrics())
}
return {
contentType: Registry.PROMETHEUS_CONTENT_TYPE,
body: parts.filter(Boolean).join('\n')
}
}
/**
* A registry holding this instance's view of the wiki, read fresh.
*
* Every count is cluster-wide they come out of the database, which every instance shares with
* the exception of `wiki_info`'s instance id and the uptime, which are this process's. A scrape
* that lands on a different instance of an HA set therefore reports the same wiki and a different
* uptime, which is what those two labels are for.
*/
private async collectWikiMetrics(): Promise<Registry> {
const register = new Registry()
const gauge = (name: string, help: string) => new Gauge({ name, help, registers: [register] })
const [
pagesTotal,
usersTotal,
usersActive,
groupsTotal,
sitesTotal,
tagsTotal,
assetsAggregate,
jobsQueued,
jobsActive,
submissionsPending,
schedulerHealthy
] = await Promise.all([
WIKI.db.$count(pagesTable),
WIKI.db.$count(usersTable, eq(usersTable.isSystem, false)),
WIKI.db.$count(usersTable, sql`${usersTable.isSystem} = false AND ${usersTable.isActive}`),
WIKI.db.$count(groupsTable),
WIKI.db.$count(sitesTable),
WIKI.db.$count(tagsTable),
WIKI.db
.select({
total: sql<number>`count(*)::int`,
bytes: sql<number>`coalesce(sum(${assetsTable.fileSize}), 0)::bigint`
})
.from(assetsTable),
WIKI.db.$count(jobsTable),
WIKI.models.jobs.countActive(),
WIKI.db.$count(submissionsTable),
WIKI.models.jobs.isHealthy()
])
new Gauge({
name: 'wiki_info',
help: 'Wiki.js build and instance this scrape was answered by. Always 1.',
labelNames: ['version', 'instance'],
registers: [register]
}).set({ version: WIKI.version, instance: WIKI.INSTANCE_ID }, 1)
gauge('wiki_start_time_seconds', 'Unix time at which this instance finished booting.').set(
WIKI.startedAt.epochMilliseconds / 1000
)
gauge('wiki_pages_total', 'Pages across every site and locale.').set(pagesTotal)
gauge('wiki_users_total', 'User accounts, excluding the system accounts.').set(usersTotal)
gauge('wiki_users_active_total', 'User accounts that are active, i.e. able to log in.').set(
usersActive
)
gauge('wiki_groups_total', 'Groups.').set(groupsTotal)
gauge('wiki_sites_total', 'Sites served by this wiki.').set(sitesTotal)
gauge('wiki_tags_total', 'Distinct tags in use.').set(tagsTotal)
gauge('wiki_assets_total', 'Uploaded files.').set(assetsAggregate[0]?.total ?? 0)
gauge('wiki_assets_size_bytes', 'Total size of uploaded files.').set(
Number(assetsAggregate[0]?.bytes ?? 0)
)
gauge('wiki_jobs_queued', 'Jobs waiting to be picked up by an instance.').set(jobsQueued)
gauge('wiki_jobs_active', 'Jobs running right now, across every instance.').set(jobsActive)
gauge('wiki_page_edit_submissions_pending', 'Suggested edits waiting for a reviewer.').set(
submissionsPending
)
gauge(
'wiki_scheduler_healthy',
'Whether some instance is still queueing scheduled jobs. 1 or 0.'
).set(schedulerHealthy ? 1 : 0)
return register
}
}
export const metrics = new Metrics()

@ -119,7 +119,13 @@ class Settings {
{
key: 'metrics',
value: {
isEnabled: false
isEnabled: false,
path: '/metrics',
allowAnonymousLocal: true,
allowAnonymousPrivate: true,
allowAnonymousExternal: false,
includeRuntime: true,
includeWiki: false
}
},
{

@ -50,6 +50,7 @@
"openid-client": "6.8.4",
"pg": "8.23.0",
"poolifier": "5.3.2",
"prom-client": "15.1.3",
"qrcode": "1.5.4",
"sanitize-html": "2.17.6",
"semver": "7.8.5",
@ -2461,6 +2462,15 @@
],
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@oxfmt/binding-android-arm-eabi": {
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz",
@ -4181,6 +4191,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bintrees": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@ -7011,6 +7027,20 @@
],
"license": "MIT"
},
"node_modules/prom-client": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
"deprecated": "prom-client has been replaced by @prometheus-io/client",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.0",
"tdigest": "^0.1.1"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@ -7652,6 +7682,15 @@
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"license": "MIT"
},
"node_modules/tdigest": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.3.tgz",
"integrity": "sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==",
"license": "MIT",
"dependencies": {
"bintrees": "1.0.2"
}
},
"node_modules/teeny-request": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-11.0.1.tgz",

@ -76,6 +76,7 @@
"openid-client": "6.8.4",
"pg": "8.23.0",
"poolifier": "5.3.2",
"prom-client": "15.1.3",
"qrcode": "1.5.4",
"sanitize-html": "2.17.6",
"semver": "7.8.5",

@ -1,5 +1,5 @@
<template>
<w-item-section avatar>
<w-item-section avatar :top="top">
<w-avatar
class="blueprint-icon"
:color="avatarBgColor"
@ -8,13 +8,13 @@
rounded
:style="props.hueRotate !== 0 ? `filter: hue-rotate(` + props.hueRotate + `deg)` : ``">
<w-badge v-if="indicatorDot" rounded :color="indicatorDot" floating>
<w-tooltip v-if="props.indicatorText">{{props.indicatorText}}</w-tooltip>
<w-tooltip v-if="props.indicatorText">{{ props.indicatorText }}</w-tooltip>
</w-badge>
<w-icon
v-if="!textMode"
:name="`img:/_assets/icons/ultraviolet-` + icon + `.svg`"
size="sm" />
<span class="uppercase" v-else>{{props.text}}</span>
<span class="uppercase" v-else>{{ props.text }}</span>
</w-avatar>
</w-item-section>
</template>
@ -29,6 +29,15 @@ const props = defineProps({
type: String,
default: ''
},
/**
* Sit at the top of the row rather than centred in it. For a row whose section runs to several
* lines a hint plus a warning, a stack of checkboxes where a centred icon drifts away from
* the label it belongs to.
*/
top: {
type: Boolean,
default: false
},
indicator: {
type: String,
default: null

@ -755,6 +755,13 @@ const permissions = [
restrictedForSystem: true,
disabled: false
},
{
permission: 'read:metrics',
hint: 'Can scrape the Prometheus metrics endpoint from an address it is not open to anonymously.',
warning: false,
restrictedForSystem: true,
disabled: false
},
{
permission: 'manage:navigation',
hint: 'Can manage site navigation',

@ -79,7 +79,7 @@
:placeholder="placeholder"
:readonly="readonly"
:disabled="disable || disabled"
:autocomplete="autocomplete"
v-bind="autofillAttrs"
:rows="type === 'textarea' ? rows : undefined"
:aria-invalid="hasError || undefined"
:aria-required="required || undefined"
@ -244,6 +244,18 @@ const props = defineProps({
type: String,
default: null
},
/**
* Keep browsers and password managers out of this field entirely.
*
* `autocomplete` alone only talks to the browser, and a field holding somebody ELSE's credential
* -- an SMTP account, a storage target's key -- is exactly what a password manager offers to fill
* with the operator's own, and then offers to save over afterwards. Each vendor reads its own
* opt-out attribute, so all of them go on together; see `autofillAttrs`.
*/
noAutofill: {
type: Boolean,
default: false
},
/**
* Put the caret in this field as soon as it is on screen.
*
@ -338,6 +350,26 @@ const isRevealed = ref(false)
const hasError = computed(() => Boolean(errorMessage.value))
/*
The opt-out attributes, as one object bound in a single `v-bind`.
`autocomplete="off"` is the standards half and the only one any browser reads; the four `data-`
attributes are what the password managers that ignore it read instead -- 1Password, LastPass,
Bitwarden and Dashlane respectively, each having settled on its own spelling. They are inert
everywhere else, so they cost a field that nobody's extension looks at nothing.
*/
const autofillAttrs = computed(() =>
props.noAutofill
? {
autocomplete: 'off',
'data-1p-ignore': 'true',
'data-lpignore': 'true',
'data-bwignore': 'true',
'data-form-type': 'other'
}
: { autocomplete: props.autocomplete }
)
/** A revealed password field renders as plain text; every other type is passed through unchanged. */
const effectiveType = computed(() =>
props.type === 'password' && props.revealable && isRevealed.value ? 'text' : props.type

@ -776,8 +776,8 @@ There are two kinds, granted separately and checked in different places.
## Global permissions
Held site-wide, bound to no path. \`access:admin\`, \`read:users\`, \`manage:users\`, \`read:groups\`,
\`manage:groups\`, \`read:audit\`, \`manage:navigation\`, \`manage:theme\`, \`manage:sites\`,
\`manage:system\`. That list is the whole of it.
\`manage:groups\`, \`read:audit\`, \`read:metrics\`, \`manage:navigation\`, \`manage:theme\`,
\`manage:sites\`, \`manage:system\`. That list is the whole of it.
\`manage:system\` bypasses every check everywhere.

@ -438,7 +438,7 @@
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-windsock.svg" />
</w-item-section>
<w-item-section>{{ t('admin.dev.flags.title') }}</w-item-section>
<w-item-section>{{ t('admin.flags.title') }}</w-item-section>
</w-item>
</template>
</template>

@ -113,6 +113,7 @@
outlined
v-model="state.config.host"
dense
no-autofill
hide-bottom-space
:aria-label="t(`admin.mail.smtpHost`)" />
</w-item-section>
@ -129,6 +130,7 @@
outlined
v-model="state.config.port"
dense
no-autofill
:aria-label="t(`admin.mail.smtpPort`)" />
</w-item-section>
</w-item>
@ -168,6 +170,7 @@
outlined
v-model="state.config.user"
dense
no-autofill
:aria-label="t(`admin.mail.smtpUser`)" />
</w-item-section>
</w-item>
@ -179,10 +182,16 @@
<w-item-label caption>{{ t(`admin.mail.smtpPwdHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<!-- -> Masked, with WInput's own reveal toggle: what arrives from the server is a mask
rather than the password, so the only value ever readable here is one the
operator is typing at that moment and may want to check -->
<w-input
outlined
v-model="state.config.pass"
dense
type="password"
revealable
no-autofill
:aria-label="t(`admin.mail.smtpPwd`)" />
</w-item-section>
</w-item>
@ -198,6 +207,7 @@
outlined
v-model="state.config.name"
dense
no-autofill
hide-bottom-space
:aria-label="t(`admin.mail.smtpName`)" />
</w-item-section>
@ -275,6 +285,7 @@
outlined
v-model="state.config.dkimPrivateKey"
dense
no-autofill
:aria-label="t(`admin.mail.dkimPrivateKey`)"
type="textarea" />
</w-item-section>

@ -1,11 +1,13 @@
<template>
<w-page class="admin-api">
<w-page class="admin-metrics">
<div class="flex flex-wrap p-4 items-center">
<div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-graph.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.metrics.title') }}</div>
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.metrics.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.metrics.subtitle') }}
</div>
@ -52,11 +54,18 @@
@click="globalSwitch"
:loading="state.isToggleLoading"
:disabled="state.loading > 0" />
<w-btn
unelevated
icon="mdi:check"
:label="t(`common.actions.apply`)"
color="secondary"
@click="save"
:loading="state.loading > 0" />
</div>
</div>
<w-separator inset />
<div class="grid grid-cols-12 p-4 gap-4">
<div class="col-span-12">
<div class="col-span-12 lg:col-span-6">
<w-card
class="rounded"
flat
@ -67,23 +76,101 @@
</w-card-section>
<w-card-section>
<i18n-t tag="span" keypath="admin.metrics.endpoint" scope="global">
<template #endpoint><strong class="font-robotomono">/metrics</strong></template>
<template #endpoint>
<strong class="font-robotomono">{{ state.config.path }}</strong>
</template>
</i18n-t>
<div class="text-caption">{{ t('admin.metrics.endpointWarning') }}</div>
<!-- The state is stored, but no route serves it yet say so rather than let the card -->
<!-- above read as a promise -->
<i18n-t
class="text-caption text-orange"
tag="div"
keypath="admin.metrics.notImplemented"
scope="global">
<template #endpoint><strong class="font-robotomono">/metrics</strong></template>
</i18n-t>
</w-card-section>
</w-card-section>
</w-card>
<!-- ----------------------- -->
<!-- Configuration -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4">
<w-card-header>{{ t('admin.metrics.configuration') }}</w-card-header>
<w-item>
<blueprint-icon icon="link" top />
<w-item-section>
<w-item-label>{{ t(`admin.metrics.path`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.metrics.pathHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 240px">
<w-input
outlined
dense
v-model="state.config.path"
:placeholder="`/metrics`"
:aria-label="t(`admin.metrics.path`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="no-access" top />
<w-item-section>
<w-item-label>{{ t(`admin.metrics.anonymousAccess`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.metrics.anonymousAccessHint`) }}</w-item-label>
<!-- -> An address only means what it looks like when the proxy headers are trusted -->
<w-item-label
v-if="!state.trustProxy"
class="mb-2 text-caption text-orange flex items-center">
<w-icon class="mr-1" name="la:exclamation-triangle" size="xs" />
{{ t('admin.metrics.proxyWarning') }}
</w-item-label>
<div class="mt-3 flex flex-col gap-3">
<!-- -> The parentheticals are appended here rather than written into the strings
a translator is handed: they are the same in every language, and a typo in one
would describe an access rule that is not the one being applied -->
<w-checkbox
v-model="state.config.allowAnonymousLocal"
:label="`${t('admin.metrics.anonymousLocal')} (${LOCAL_ADDRESSES})`" />
<w-checkbox
v-model="state.config.allowAnonymousPrivate"
:label="`${t('admin.metrics.anonymousPrivate')} (${PRIVATE_STANDARDS})`" />
<w-checkbox
v-model="state.config.allowAnonymousExternal"
color="negative"
:label="t(`admin.metrics.anonymousExternal`)" />
</div>
<!-- -> Ticking the last box is what makes the wiki's internals world-readable, so it
says so where it is ticked rather than in the card above -->
<w-item-label
v-if="state.config.allowAnonymousExternal"
class="pl-7 text-caption text-negative flex items-center">
<w-icon class="mr-1" name="la:exclamation-triangle" size="xs" />
{{ t('admin.metrics.anonymousExternalWarning') }}
</w-item-label>
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="sigma" top />
<w-item-section>
<w-item-label>{{ t(`admin.metrics.included`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.metrics.includedHint`) }}</w-item-label>
<div class="mt-3 flex flex-col gap-3">
<w-checkbox
v-model="state.config.includeRuntime"
:label="t(`admin.metrics.includeRuntime`)" />
<div>
<w-checkbox
v-model="state.config.includeWiki"
:label="t(`admin.metrics.includeWiki`)" />
<!-- -> Under the box rather than in the hint above, because it is the cost of
this option specifically: every one of these gauges is a count read fresh -->
<div class="pl-7 text-caption text-orange flex items-start">
<w-icon class="mr-1 mt-px" name="la:exclamation-triangle" size="xs" />
<span>{{ t('admin.metrics.includeWikiWarning') }}</span>
</div>
</div>
</div>
</w-item-section>
</w-item>
</w-card>
</div>
<div class="col-span-12 lg:col-span-6">
<w-card
class="rounded mt-4"
class="rounded"
flat
:class="dark.isActive ? `bg-dark-5 text-white` : `bg-grey-3 text-dark`">
<w-card-section class="items-center" horizontal>
@ -92,18 +179,62 @@
</w-card-section>
<w-card-section>
<i18n-t tag="span" keypath="admin.metrics.auth" scope="global">
<template #headerName>
<strong class="font-robotomono">Authorization</strong>
</template>
<template #tokenType><strong class="font-robotomono">Bearer</strong></template>
<template #permission>
<strong class="font-robotomono">read:metrics</strong>
</template>
</i18n-t>
<div class="text-caption mt-2">
<i18n-t keypath="admin.metrics.authApiKey" scope="global">
<template #headerName>
<strong class="font-robotomono">Authorization</strong>
</template>
<template #tokenType><strong class="font-robotomono">Bearer</strong></template>
</i18n-t>
</div>
<div class="text-caption font-robotomono">Authorization: Bearer API-KEY-VALUE</div>
</w-card-section>
</w-card-section>
</w-card>
<!-- ----------------------- -->
<!-- Preview -->
<!-- ----------------------- -->
<w-card class="mt-4">
<w-card-header>
{{ t('admin.metrics.preview') }}
<template #hint>{{ t('admin.metrics.previewHint') }}</template>
<template #action>
<w-btn
class="acrylic-btn"
icon="la:redo-alt"
flat
dense
color="secondary"
:loading="state.preview.loading"
:aria-label="t(`common.actions.refresh`)"
@click="loadPreview">
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
</w-btn>
</template>
</w-card-header>
<w-card-section class="pt-0">
<div v-if="state.preview.error" class="text-caption text-negative flex items-start">
<w-icon class="mr-1 mt-px" name="la:exclamation-triangle" size="xs" />
<span>{{ state.preview.error }}</span>
</div>
<!-- -> Both boxes unticked is a state the form can be in but the endpoint cannot be
saved in, so it is worth saying rather than showing an empty box -->
<div v-else-if="!state.preview.body" class="text-caption text-grey">
{{ t('admin.metrics.previewEmpty') }}
</div>
<template v-else>
<pre
class="max-h-96 overflow-auto rounded bg-black/5 p-3 text-caption dark:bg-white/5"><code>{{ state.preview.body }}</code></pre>
<div class="text-caption text-grey mt-2">
{{ t('admin.metrics.previewSize', { lines: previewLines, bytes: previewBytes }) }}
</div>
</template>
</w-card-section>
</w-card>
</div>
</div>
</w-page>
@ -111,7 +242,7 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { onMounted, reactive } from 'vue'
import { computed, onMounted, reactive, watch } from 'vue'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
@ -141,24 +272,74 @@ useMeta({
title: t('admin.metrics.title')
})
/**
* The loopback addresses the `local` class is named by, shown beside its label.
*
* Not translated content `helpers/network.ts` is what actually decides the class, and it matches
* the whole of `127.0.0.0/8` alongside `::1`; these are the two addresses a reader recognises.
*/
const LOCAL_ADDRESSES = '127.0.0.1, ::1'
/**
* The standards the `private` class is drawn from, shown beside its label.
*
* All four that `helpers/network.ts` actually implements, in number order: 1918 for the IPv4 private
* ranges, 3927 and 4291 for IPv4 and IPv6 link-local, 4193 for IPv6 unique local addresses. No single
* one of them covers the class, so naming only the familiar first would describe a narrower rule than
* the one being applied.
*/
const PRIVATE_STANDARDS = 'RFC 1918, 3927, 4193, 4291'
// DATA
const state = reactive({
enabled: false,
loading: 0,
isToggleLoading: false
isToggleLoading: false,
// -> Read only, and only to warn: with the proxy headers untrusted every request carries the
// proxy's address, so the three classes below are not what an operator would expect
trustProxy: true,
config: {
path: '/metrics',
allowAnonymousLocal: true,
allowAnonymousPrivate: true,
allowAnonymousExternal: false,
includeRuntime: true,
includeWiki: false
},
preview: {
body: '',
loading: false,
error: null
}
})
// COMPUTED
const previewLines = computed(() =>
state.preview.body ? state.preview.body.trimEnd().split('\n').length : 0
)
// -> What a scrape actually transfers, so multi-byte characters in a label count for what they cost
const previewBytes = computed(() => new TextEncoder().encode(state.preview.body).length)
// METHODS
async function load() {
state.loading++
loading.show()
try {
const resp = await API_CLIENT.get('system/metrics').json()
state.enabled = resp?.isEnabled === true
const [config, security] = await Promise.all([
API_CLIENT.get('system/metrics').json(),
API_CLIENT.get('system/security').json()
])
state.enabled = config?.isEnabled === true
state.config = { ...state.config, ...config }
state.trustProxy = security?.trustProxy === true
// -> Keeps the status light in the admin sidebar in step without another round trip
adminStore.info.isMetricsEnabled = state.enabled
// -> Not awaited: collecting the wiki gauges is a dozen queries, and the form should not sit
// behind them
loadPreview()
} catch (err) {
notify({
type: 'negative',
@ -170,6 +351,31 @@ async function load() {
state.loading--
}
/**
* What a scrape would be answered with.
*
* Asks the API rather than fetching the endpoint itself: this has to work while the endpoint is off,
* and it previews the boxes as they are ticked right now rather than as they were last saved so
* the answer to "what does turning this on actually expose" is one click, not save-then-look.
*/
async function loadPreview() {
state.preview.loading = true
state.preview.error = null
try {
const resp = await API_CLIENT.get('system/metrics/preview', {
searchParams: {
includeRuntime: state.config.includeRuntime === true,
includeWiki: state.config.includeWiki === true
}
}).json()
state.preview.body = resp?.body ?? ''
} catch (err) {
state.preview.body = ''
state.preview.error = apiErrorMessage(err)
}
state.preview.loading = false
}
async function refresh() {
await load()
notify({
@ -178,6 +384,39 @@ async function refresh() {
})
}
async function save() {
state.loading++
try {
const resp = await API_CLIENT.put('system/metrics', {
json: {
path: state.config.path,
allowAnonymousLocal: state.config.allowAnonymousLocal,
allowAnonymousPrivate: state.config.allowAnonymousPrivate,
allowAnonymousExternal: state.config.allowAnonymousExternal,
includeRuntime: state.config.includeRuntime,
includeWiki: state.config.includeWiki
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occurred.')
}
notify({
type: 'positive',
message: t('admin.metrics.saveSuccess')
})
await load()
} catch (err) {
// -> ky throws above 400 the server refuses a path that would shadow the wiki itself, and an
// exposition with nothing in it
notify({
type: 'negative',
message: t('admin.metrics.saveFailed'),
caption: apiErrorMessage(err)
})
}
state.loading--
}
async function globalSwitch() {
state.isToggleLoading = true
const wanted = !state.enabled
@ -205,6 +444,21 @@ async function globalSwitch() {
state.isToggleLoading = false
}
// WATCHERS
/*
The preview claims to show the options as they are ticked, so it has to follow them rather than
wait for Apply ticking the wiki group and reading what it would expose is the question this card
exists to answer. Only these two: the path and the anonymous-access settings change who reaches the
endpoint and where, not a byte of what it says.
*/
watch(
() => [state.config.includeRuntime, state.config.includeWiki],
() => {
loadPreview()
}
)
// MOUNTED
onMounted(load)

Loading…
Cancel
Save