diff --git a/server/app/data.yml b/server/app/data.yml
index 4d0ef92bc0..12535a18a6 100644
--- a/server/app/data.yml
+++ b/server/app/data.yml
@@ -41,6 +41,8 @@ defaults:
host: ''
secure: true
verifySSL: true
+ metrics:
+ isEnabled: false
auth:
autoLogin: false
enforce2FA: false
diff --git a/server/controllers/common.mjs b/server/controllers/common.mjs
index 5ffe883081..b796eed66e 100644
--- a/server/controllers/common.mjs
+++ b/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
// */
diff --git a/server/core/kernel.mjs b/server/core/kernel.mjs
index 560b782415..eb0a1d7c01 100644
--- a/server/core/kernel.mjs
+++ b/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)
diff --git a/server/core/metrics.mjs b/server/core/metrics.mjs
new file mode 100644
index 0000000000..9ffa25df71
--- /dev/null
+++ b/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)
+ }
+ }
+}
diff --git a/server/db/migrations/3.0.0.mjs b/server/db/migrations/3.0.0.mjs
index c1f3c39c11..795d3c4d38 100644
--- a/server/db/migrations/3.0.0.mjs
+++ b/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: {
diff --git a/server/graph/resolvers/system.mjs b/server/graph/resolvers/system.mjs
index 4fd7d89876..6f3eb48635 100644
--- a/server/graph/resolvers/system.mjs
+++ b/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'])) {
diff --git a/server/graph/schemas/system.graphql b/server/graph/schemas/system.graphql
index 4da13974f6..deff656d9e 100644
--- a/server/graph/schemas/system.graphql
+++ b/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
diff --git a/server/locales/en.json b/server/locales/en.json
index 125cb97836..a3dc805939 100644
--- a/server/locales/en.json
+++ b/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",
diff --git a/server/package.json b/server/package.json
index 86658f2489..9b704e3c83 100644
--- a/server/package.json
+++ b/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",
diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml
index fd26a6f58f..a30e79d66f 100644
--- a/server/pnpm-lock.yaml
+++ b/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
diff --git a/ux/public/_assets/icons/fluent-graph.svg b/ux/public/_assets/icons/fluent-graph.svg
new file mode 100644
index 0000000000..ce8f29cf66
--- /dev/null
+++ b/ux/public/_assets/icons/fluent-graph.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ux/src/layouts/AdminLayout.vue b/ux/src/layouts/AdminLayout.vue
index 051fafd8b2..c251565377 100644
--- a/ux/src/layouts/AdminLayout.vue
+++ b/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')
diff --git a/ux/src/pages/AdminMetrics.vue b/ux/src/pages/AdminMetrics.vue
new file mode 100644
index 0000000000..cfc0c7e2f7
--- /dev/null
+++ b/ux/src/pages/AdminMetrics.vue
@@ -0,0 +1,188 @@
+
+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
+
+
+
+
+
diff --git a/ux/src/router/routes.js b/ux/src/router/routes.js
index 0d23e5bfe4..8a3b15e662 100644
--- a/ux/src/router/routes.js
+++ b/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') },
diff --git a/ux/src/stores/admin.js b/ux/src/stores/admin.js
index 0562e52f16..4a6c2e03af 100644
--- a/ux/src/stores/admin.js
+++ b/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)
},