mirror of
https://github.com/requarks/wiki.git
synced 2025-02-25 18:55:28 -06:00
feat: metrics endpoint
This commit is contained in:
@@ -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)
|
||||
|
||||
66
server/core/metrics.mjs
Normal file
66
server/core/metrics.mjs
Normal file
@@ -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",
|
||||
|
||||
21
server/pnpm-lock.yaml
generated
21
server/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
1
ux/public/_assets/icons/fluent-graph.svg
Normal file
1
ux/public/_assets/icons/fluent-graph.svg
Normal file
@@ -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')
|
||||
|
||||
188
ux/src/pages/AdminMetrics.vue
Normal file
188
ux/src/pages/AdminMetrics.vue
Normal file
@@ -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)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user