feat: metrics endpoint

pull/7004/head
NGPixel 1 year ago
parent 4f4b05e551
commit dec9272fbf
No known key found for this signature in database
GPG Key ID: B755FB6870B30F63

@ -41,6 +41,8 @@ defaults:
host: ''
secure: true
verifySSL: true
metrics:
isEnabled: false
auth:
autoLogin: false
enforce2FA: false

@ -434,6 +434,21 @@ export default function () {
return res.sendStatus(404)
})
/**
* Metrics (Prometheus)
*/
router.get('/metrics', async (req, res, next) => {
if (!WIKI.auth.checkAccess(req.user, ['read:metrics'])) {
return res.sendStatus(403)
}
if (WIKI.config.metrics.isEnabled) {
WIKI.metrics.render(res)
} else {
next()
}
})
// /**
// * View document / asset
// */

@ -7,6 +7,7 @@ import db from './db.mjs'
import extensions from './extensions.mjs'
import scheduler from './scheduler.mjs'
import servers from './servers.mjs'
import metrics from './metrics.mjs'
let isShuttingDown = false
@ -47,6 +48,7 @@ export default {
}
WIKI.extensions = extensions
WIKI.asar = asar
WIKI.metrics = await metrics.init()
} catch (err) {
WIKI.logger.error(err)
process.exit(1)

@ -0,0 +1,66 @@
import { collectDefaultMetrics, register, Gauge } from 'prom-client'
import { toSafeInteger } from 'lodash-es'
export default {
customMetrics: {},
async init () {
if (WIKI.config.metrics.isEnabled) {
WIKI.logger.info('Initializing metrics...')
register.setDefaultLabels({
WIKI_INSTANCE: WIKI.INSTANCE_ID
})
collectDefaultMetrics()
this.customMetrics.groupsTotal = new Gauge({
name: 'wiki_groups_total',
help: 'Total number of groups',
async collect() {
const total = await WIKI.db.groups.query().count('* as total').first()
this.set(toSafeInteger(total.total))
}
})
this.customMetrics.pagesTotal = new Gauge({
name: 'wiki_pages_total',
help: 'Total number of pages',
async collect() {
const total = await WIKI.db.pages.query().count('* as total').first()
this.set(toSafeInteger(total.total))
}
})
this.customMetrics.tagsTotal = new Gauge({
name: 'wiki_tags_total',
help: 'Total number of tags',
async collect() {
const total = await WIKI.db.tags.query().count('* as total').first()
this.set(toSafeInteger(total.total))
}
})
this.customMetrics.usersTotal = new Gauge({
name: 'wiki_users_total',
help: 'Total number of users',
async collect() {
const total = await WIKI.db.users.query().count('* as total').first()
this.set(toSafeInteger(total.total))
}
})
WIKI.logger.info('Metrics ready [ OK ]')
} else {
this.customMetrics = {}
register.clear()
}
return this
},
async render (res) {
try {
res.contentType(register.contentType)
res.send(await register.metrics())
} catch (err) {
res.status(500).end(err.message)
}
}
}

@ -457,6 +457,12 @@ export async function up (knex) {
})
await knex('settings').insert([
{
key: 'api',
value: {
isEnabled: false
}
},
{
key: 'auth',
value: {
@ -516,6 +522,12 @@ export async function up (knex) {
dkimPrivateKey: ''
}
},
{
key: 'metrics',
value: {
isEnabled: false
}
},
{
key: 'search',
value: {

@ -12,6 +12,12 @@ const getos = util.promisify(getosSync)
export default {
Query: {
/**
* Metrics Endpoint State
*/
metricsState () {
return WIKI.config.metrics?.isEnabled ?? false
},
/**
* System Flags
*/
@ -281,6 +287,24 @@ export default {
return generateError(err)
}
},
/**
* Set Metrics endpoint state
*/
async setMetricsState (obj, args, context) {
try {
if (!WIKI.auth.checkAccess(context.req.user, ['manage:system'])) {
throw new Error('ERR_FORBIDDEN')
}
WIKI.config.metrics.isEnabled = args.enabled
await WIKI.configSvc.saveToDb(['metrics'])
return {
operation: generateSuccess('Metrics endpoint state changed successfully')
}
} catch (err) {
return generateError(err)
}
},
async updateSystemFlags (obj, args, context) {
try {
if (!WIKI.auth.checkAccess(context.req.user, ['manage:system'])) {

@ -3,6 +3,7 @@
# ===============================================
extend type Query {
metricsState: Boolean
systemExtensions: [SystemExtension]
systemFlags: JSON
systemInfo: SystemInfo
@ -35,6 +36,10 @@ extend type Mutation {
id: UUID!
): DefaultResponse
setMetricsState(
enabled: Boolean!
): DefaultResponse
updateSystemSearch(
termHighlighting: Boolean
dictOverrides: String

@ -466,6 +466,16 @@
"admin.mail.testRecipientHint": "Email address that should receive the test email.",
"admin.mail.testSend": "Send Email",
"admin.mail.title": "Mail",
"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.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.refreshSuccess": "Metrics endpoint state has been refreshed.",
"admin.metrics.subtitle": "Manage the Prometheus metrics endpoint",
"admin.metrics.title": "Metrics",
"admin.metrics.toggleStateDisabledSuccess": "Metrics endpoint disabled successfully.",
"admin.metrics.toggleStateEnabledSuccess": "Metrics endpoint enabled successfully.",
"admin.nav.modules": "Modules",
"admin.nav.site": "Site",
"admin.nav.system": "System",

@ -149,6 +149,7 @@
"pg-query-stream": "4.5.3",
"pg-tsquery": "8.4.1",
"poolifier": "2.7.5",
"prom-client": "15.0.0",
"punycode": "2.3.0",
"puppeteer-core": "21.4.0",
"qr-image": "3.2.0",

@ -344,6 +344,9 @@ dependencies:
poolifier:
specifier: 2.7.5
version: 2.7.5
prom-client:
specifier: 15.0.0
version: 15.0.0
punycode:
specifier: 2.3.0
version: 2.3.0
@ -2252,6 +2255,10 @@ packages:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
/bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
dev: false
/bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
dependencies:
@ -6138,6 +6145,14 @@ packages:
engines: {node: '>=0.4.0'}
dev: false
/prom-client@15.0.0:
resolution: {integrity: sha512-UocpgIrKyA2TKLVZDSfm8rGkL13C19YrQBAiG3xo3aDFWcHedxRxI3z+cIcucoxpSO0h5lff5iv/SXoxyeopeA==}
engines: {node: ^16 || ^18 || >=20}
dependencies:
'@opentelemetry/api': 1.6.0
tdigest: 0.1.2
dev: false
/promised-retry@0.5.0:
resolution: {integrity: sha512-jbYvN6UGE+/3E1g0JmgDPchUc+4VI4cBaPjdr2Lso22xfFqut2warEf6IhWuhPJKbJYVOQAyCt2Jx+01ORCItg==}
engines: {node: ^14.17.0 || >=16.0.0}
@ -6967,6 +6982,12 @@ packages:
engines: {node: '>=8.0.0'}
dev: false
/tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
dependencies:
bintrees: 1.0.2
dev: false
/text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#107c42" d="M6.999,25c-0.503,0-0.937-0.378-0.993-0.89c-0.061-0.549,0.335-1.043,0.884-1.104l8.688-0.965 l7.764-6.794c0.09-0.079,0.194-0.142,0.307-0.184l7.798-2.924l7.847-7.847c0.391-0.391,1.023-0.391,1.414,0s0.391,1.023,0,1.414 l-8,8c-0.101,0.101-0.223,0.179-0.355,0.229l-7.83,2.936l-7.863,6.881c-0.153,0.134-0.345,0.219-0.548,0.241l-9,1 C7.073,24.998,7.035,25,6.999,25z"/><path fill="#61e3a7" d="M44,19v24h-8V19c0-0.552,0.448-1,1-1h6C43.552,18,44,18.448,44,19z"/><path fill="#33c481" d="M36,25v18h-8V26c0-0.552,0.448-1,1-1H36z"/><path fill="#21a366" d="M28,29v14h-8V30c0-0.552,0.448-1,1-1H28z"/><path fill="#107c42" d="M20,33v10h-8v-9c0-0.552,0.448-1,1-1H20z"/><path fill="#185c37" d="M12,37v6H4v-5c0-0.552,0.448-1,1-1H12z"/><circle cx="32" cy="13" r="2" fill="#33c481"/><circle cx="24" cy="16" r="2" fill="#33c481"/><circle cx="16" cy="23" r="2" fill="#33c481"/><circle cx="7" cy="24" r="2" fill="#33c481"/><path fill="#33c481" d="M41.014,8.916l0.856-5.135c0.064-0.383-0.268-0.715-0.651-0.651l-5.135,0.856 c-0.454,0.076-0.632,0.633-0.307,0.958l4.279,4.279C40.381,9.548,40.938,9.37,41.014,8.916z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -181,6 +181,12 @@ q-layout.admin(view='hHh Lpr lff')
q-item-section {{ t('admin.mail.title') }}
q-item-section(side)
status-light(:color='adminStore.info.isMailConfigured ? `positive` : `warning`', :pulse='!adminStore.info.isMailConfigured')
q-item(to='/_admin/metrics', v-ripple, active-class='bg-primary text-white')
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-graph.svg')
q-item-section {{ t('admin.metrics.title') }}
q-item-section(side)
status-light(:color='adminStore.info.isMetricsEnabled ? `positive` : `negative`')
q-item(to='/_admin/rendering', v-ripple, active-class='bg-primary text-white')
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')

@ -0,0 +1,188 @@
<template lang='pug'>
q-page.admin-api
.row.q-pa-md.items-center
.col-auto
img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-graph.svg')
.col.q-pl-md
.text-h5.text-primary.animated.fadeInLeft {{ t('admin.metrics.title') }}
.text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.metrics.subtitle') }}
.col
.flex.items-center
template(v-if='state.enabled')
q-spinner-rings.q-mr-sm(color='green', size='md')
.text-caption.text-green {{t('admin.metrics.enabled')}}
template(v-else)
q-spinner-rings.q-mr-sm(color='red', size='md')
.text-caption.text-red {{t('admin.metrics.disabled')}}
.col-auto
q-btn.q-mr-sm.q-ml-md.acrylic-btn(
icon='las la-question-circle'
flat
color='grey'
:aria-label='t(`common.actions.viewDocs`)'
:href='siteStore.docsBase + `/system/metrics`'
target='_blank'
type='a'
)
q-tooltip {{ t(`common.actions.viewDocs`) }}
q-btn.acrylic-btn.q-mr-sm(
icon='las la-redo-alt'
flat
color='secondary'
:loading='state.loading > 0'
:aria-label='t(`common.actions.refresh`)'
@click='refresh'
)
q-tooltip {{ t(`common.actions.refresh`) }}
q-btn.q-mr-sm(
unelevated
icon='las la-power-off'
:label='!state.enabled ? t(`common.actions.activate`) : t(`common.actions.deactivate`)'
:color='!state.enabled ? `positive` : `negative`'
@click='globalSwitch'
:loading='state.isToggleLoading'
:disabled='state.loading > 0'
)
q-separator(inset)
.row.q-pa-md.q-col-gutter-md
.col-12
q-card.rounded-borders(
flat
:class='$q.dark.isActive ? `bg-dark-5 text-white` : `bg-grey-3 text-dark`'
)
q-card-section.items-center(horizontal)
q-card-section.col-auto.q-pr-none
q-icon(name='las la-info-circle', size='sm')
q-card-section
i18n-t(tag='span', keypath='admin.metrics.endpoint')
template(#endpoint)
strong.font-robotomono /metrics
.text-caption {{ t('admin.metrics.endpointWarning') }}
q-card.rounded-borders.q-mt-md(
flat
:class='$q.dark.isActive ? `bg-dark-5 text-white` : `bg-grey-3 text-dark`'
)
q-card-section.items-center(horizontal)
q-card-section.col-auto.q-pr-none
q-icon(name='las la-key', size='sm')
q-card-section
i18n-t(tag='span', keypath='admin.metrics.auth')
template(#headerName)
strong.font-robotomono Authorization
template(#tokenType)
strong.font-robotomono Bearer
template(#permission)
strong.font-robotomono read:metrics
.text-caption.font-robotomono Authorization: Bearer API-KEY-VALUE
</template>
<script setup>
import gql from 'graphql-tag'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue'
import { useAdminStore } from 'src/stores/admin'
import { useSiteStore } from 'src/stores/site'
// QUASAR
const $q = useQuasar()
// STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('admin.metrics.title')
})
// DATA
const state = reactive({
enabled: false,
loading: 0,
isToggleLoading: false
})
// METHODS
async function load () {
state.loading++
$q.loading.show()
const resp = await APOLLO_CLIENT.query({
query: gql`
query getMetricsState {
metricsState
}
`,
fetchPolicy: 'network-only'
})
state.enabled = resp?.data?.metricsState === true
adminStore.info.isMetricsEnabled = state.enabled
$q.loading.hide()
state.loading--
}
async function refresh () {
await load()
$q.notify({
type: 'positive',
message: t('admin.metrics.refreshSuccess')
})
}
async function globalSwitch () {
state.isToggleLoading = true
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation ($enabled: Boolean!) {
setMetricsState (enabled: $enabled) {
operation {
succeeded
message
}
}
}
`,
variables: {
enabled: !state.enabled
}
})
if (resp?.data?.setMetricsState?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: state.enabled ? t('admin.metrics.toggleStateDisabledSuccess') : t('admin.metrics.toggleStateEnabledSuccess')
})
await load()
} else {
throw new Error(resp?.data?.setMetricsState?.operation?.message || 'An unexpected error occurred.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to switch metrics endpoint state.',
caption: err.message
})
}
state.isToggleLoading = false
}
// MOUNTED
onMounted(load)
</script>
<style lang='scss'>
</style>

@ -62,6 +62,7 @@ const routes = [
{ path: 'icons', component: () => import('pages/AdminIcons.vue') },
{ path: 'instances', component: () => import('pages/AdminInstances.vue') },
{ path: 'mail', component: () => import('pages/AdminMail.vue') },
{ path: 'metrics', component: () => import('pages/AdminMetrics.vue') },
{ path: 'rendering', component: () => import('pages/AdminRendering.vue') },
{ path: 'scheduler', component: () => import('pages/AdminScheduler.vue') },
{ path: 'search', component: () => import('pages/AdminSearch.vue') },

@ -56,6 +56,7 @@ export const useAdminStore = defineStore('admin', {
query: gql`
query getAdminInfo {
apiState
metricsState
systemInfo {
groupsTotal
tagsTotal
@ -77,6 +78,7 @@ export const useAdminStore = defineStore('admin', {
this.info.currentVersion = clone(resp?.data?.systemInfo?.currentVersion ?? 'n/a')
this.info.latestVersion = clone(resp?.data?.systemInfo?.latestVersion ?? 'n/a')
this.info.isApiEnabled = clone(resp?.data?.apiState ?? false)
this.info.isMetricsEnabled = clone(resp?.data?.metricsState ?? false)
this.info.isMailConfigured = clone(resp?.data?.systemInfo?.isMailConfigured ?? false)
this.info.isSchedulerHealthy = clone(resp?.data?.systemInfo?.isSchedulerHealthy ?? false)
},

Loading…
Cancel
Save