瀏覽代碼

feat: metrics endpoint

NGPixel 1 年之前
父節點
當前提交
dec9272fbf

+ 2 - 0
server/app/data.yml

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

+ 15 - 0
server/controllers/common.mjs

@@ -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
   //  */

+ 2 - 0
server/core/kernel.mjs

@@ -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 - 0
server/core/metrics.mjs

@@ -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)
+    }
+  }
+}

+ 12 - 0
server/db/migrations/3.0.0.mjs

@@ -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: {

+ 24 - 0
server/graph/resolvers/system.mjs

@@ -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'])) {

+ 5 - 0
server/graph/schemas/system.graphql

@@ -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

+ 10 - 0
server/locales/en.json

@@ -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",

+ 1 - 0
server/package.json

@@ -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 - 0
server/pnpm-lock.yaml

@@ -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 - 0
ux/public/_assets/icons/fluent-graph.svg

@@ -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>

+ 6 - 0
ux/src/layouts/AdminLayout.vue

@@ -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 - 0
ux/src/pages/AdminMetrics.vue

@@ -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>

+ 1 - 0
ux/src/router/routes.js

@@ -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') },

+ 2 - 0
ux/src/stores/admin.js

@@ -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)
     },