diff --git a/CLAUDE.md b/CLAUDE.md index 0ade8a2ec..4892eebea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/backend/api/index.ts b/backend/api/index.ts index df8872751..2649a795b 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -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)) diff --git a/backend/api/schemas/metrics.ts b/backend/api/schemas/metrics.ts new file mode 100644 index 000000000..a2b03cc0b --- /dev/null +++ b/backend/api/schemas/metrics.ts @@ -0,0 +1,48 @@ +import type { FastifyInstance } from 'fastify' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * 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.' + } + } + }) +} diff --git a/backend/api/system.ts b/backend/api/system.ts index 894c82ba6..a60b36f9c 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -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 }>( + '/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.') } } ) diff --git a/backend/base.yml b/backend/base.yml index 3160c835d..30afa8982 100644 --- a/backend/base.yml +++ b/backend/base.yml @@ -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 diff --git a/backend/controllers/metrics.ts b/backend/controllers/metrics.ts new file mode 100644 index 000000000..18e80d96a --- /dev/null +++ b/backend/controllers/metrics.ts @@ -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.') + } +} diff --git a/backend/helpers/common.ts b/backend/helpers/common.ts index f88419124..47193aae7 100644 --- a/backend/helpers/common.ts +++ b/backend/helpers/common.ts @@ -62,6 +62,17 @@ export function createDeferred(): Deferred { } } +/** + * 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 * diff --git a/backend/helpers/network.ts b/backend/helpers/network.ts new file mode 100644 index 000000000..d4dd63250 --- /dev/null +++ b/backend/helpers/network.ts @@ -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' +} diff --git a/backend/index.ts b/backend/index.ts index fcc0caa0b..8b1c0ae3e 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -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 // ---------------------------------------- diff --git a/backend/locales/en.json b/backend/locales/en.json index 7cade8c6c..7edb54edb 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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", diff --git a/backend/models/index.ts b/backend/models/index.ts index 871613e15..dfb279f76 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -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, diff --git a/backend/models/metrics.ts b/backend/models/metrics.ts new file mode 100644 index 000000000..39575e743 --- /dev/null +++ b/backend/models/metrics.ts @@ -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 = { + 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 { + const metrics = WIKI.config.metrics ?? {} + const config: Record = {} + 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): Record { + const patch: Record = {} + 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 | 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): Promise { + 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 { + 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`count(*)::int`, + bytes: sql`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() diff --git a/backend/models/settings.ts b/backend/models/settings.ts index 0afaec44c..b632ab746 100644 --- a/backend/models/settings.ts +++ b/backend/models/settings.ts @@ -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 } }, { diff --git a/backend/package-lock.json b/backend/package-lock.json index 8c0b6be50..8f7128d6b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index f931860e8..876167464 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/frontend/src/components/BlueprintIcon.vue b/frontend/src/components/BlueprintIcon.vue index 8ed003f07..80127e7f9 100644 --- a/frontend/src/components/BlueprintIcon.vue +++ b/frontend/src/components/BlueprintIcon.vue @@ -1,5 +1,5 @@ @@ -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 diff --git a/frontend/src/components/GroupEditOverlay.vue b/frontend/src/components/GroupEditOverlay.vue index dfb510dfe..3ff835a11 100644 --- a/frontend/src/components/GroupEditOverlay.vue +++ b/frontend/src/components/GroupEditOverlay.vue @@ -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', diff --git a/frontend/src/components/shared/WInput.vue b/frontend/src/components/shared/WInput.vue index 2f4d21235..581a45944 100644 --- a/frontend/src/components/shared/WInput.vue +++ b/frontend/src/components/shared/WInput.vue @@ -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 diff --git a/frontend/src/helpers/sampleContent.js b/frontend/src/helpers/sampleContent.js index 77c1a55a1..3f532aa70 100644 --- a/frontend/src/helpers/sampleContent.js +++ b/frontend/src/helpers/sampleContent.js @@ -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. diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 860aceb23..3860ff21d 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -438,7 +438,7 @@ - {{ t('admin.dev.flags.title') }} + {{ t('admin.flags.title') }} diff --git a/frontend/src/pages/AdminMail.vue b/frontend/src/pages/AdminMail.vue index 57de77095..49e8143ab 100644 --- a/frontend/src/pages/AdminMail.vue +++ b/frontend/src/pages/AdminMail.vue @@ -113,6 +113,7 @@ outlined v-model="state.config.host" dense + no-autofill hide-bottom-space :aria-label="t(`admin.mail.smtpHost`)" /> @@ -129,6 +130,7 @@ outlined v-model="state.config.port" dense + no-autofill :aria-label="t(`admin.mail.smtpPort`)" /> @@ -168,6 +170,7 @@ outlined v-model="state.config.user" dense + no-autofill :aria-label="t(`admin.mail.smtpUser`)" /> @@ -179,10 +182,16 @@ {{ t(`admin.mail.smtpPwdHint`) }} + @@ -198,6 +207,7 @@ outlined v-model="state.config.name" dense + no-autofill hide-bottom-space :aria-label="t(`admin.mail.smtpName`)" /> @@ -275,6 +285,7 @@ outlined v-model="state.config.dkimPrivateKey" dense + no-autofill :aria-label="t(`admin.mail.dkimPrivateKey`)" type="textarea" /> diff --git a/frontend/src/pages/AdminMetrics.vue b/frontend/src/pages/AdminMetrics.vue index f37f227f3..a52ccd187 100644 --- a/frontend/src/pages/AdminMetrics.vue +++ b/frontend/src/pages/AdminMetrics.vue @@ -1,11 +1,13 @@