浏览代码

feat: admin instances

Nicolas Giard 2 年之前
父节点
当前提交
055fcc6b72

+ 35 - 2
server/graph/resolvers/system.js

@@ -23,13 +23,46 @@ module.exports = {
       }
       }
       return exts
       return exts
     },
     },
+    async systemInstances () {
+      const instRaw = await WIKI.db.knex('pg_stat_activity')
+        .select([
+          'usename',
+          'client_addr',
+          'application_name',
+          'backend_start',
+          'state_change'
+        ])
+        .where('datname', WIKI.db.knex.client.connectionSettings.database)
+        .andWhereLike('application_name', 'Wiki.js%')
+      const insts = {}
+      for (const inst of instRaw) {
+        const instId = inst.application_name.substring(10, 20)
+        const conType = inst.application_name.includes(':MAIN') ? 'main' : 'sub'
+        const curInst = insts[instId] ?? {
+          activeConnections: 0,
+          activeListeners: 0,
+          dbFirstSeen: inst.backend_start,
+          dbLastSeen: inst.state_change
+        }
+        insts[instId] = {
+          id: instId,
+          activeConnections: conType === 'main' ? curInst.activeConnections + 1 : curInst.activeConnections,
+          activeListeners: conType === 'sub' ? curInst.activeListeners + 1 : curInst.activeListeners,
+          dbUser: inst.usename,
+          dbFirstSeen: curInst.dbFirstSeen > inst.backend_start ? inst.backend_start : curInst.dbFirstSeen,
+          dbLastSeen: curInst.dbLastSeen < inst.state_change ? inst.state_change : curInst.dbLastSeen,
+          ip: inst.client_addr
+        }
+      }
+      return _.values(insts)
+    },
     systemSecurity () {
     systemSecurity () {
       return WIKI.config.security
       return WIKI.config.security
     },
     },
     async systemJobs (obj, args) {
     async systemJobs (obj, args) {
       const results = args.states?.length > 0 ?
       const results = args.states?.length > 0 ?
-        await WIKI.db.knex('jobHistory').whereIn('state', args.states.map(s => s.toLowerCase())).orderBy('startedAt') :
-        await WIKI.db.knex('jobHistory').orderBy('startedAt')
+        await WIKI.db.knex('jobHistory').whereIn('state', args.states.map(s => s.toLowerCase())).orderBy('startedAt', 'desc') :
+        await WIKI.db.knex('jobHistory').orderBy('startedAt', 'desc')
       return results.map(r => ({
       return results.map(r => ({
         ...r,
         ...r,
         state: r.state.toUpperCase()
         state: r.state.toUpperCase()

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

@@ -6,6 +6,7 @@ extend type Query {
   systemExtensions: [SystemExtension]
   systemExtensions: [SystemExtension]
   systemFlags: [SystemFlag]
   systemFlags: [SystemFlag]
   systemInfo: SystemInfo
   systemInfo: SystemInfo
+  systemInstances: [SystemInstance]
   systemSecurity: SystemSecurity
   systemSecurity: SystemSecurity
   systemJobs(
   systemJobs(
     states: [SystemJobState]
     states: [SystemJobState]
@@ -93,6 +94,16 @@ type SystemInfo {
   workingDirectory: String
   workingDirectory: String
 }
 }
 
 
+type SystemInstance {
+  id: String
+  activeConnections: Int
+  activeListeners: Int
+  dbUser: String
+  dbFirstSeen: Date
+  dbLastSeen: Date
+  ip: String
+}
+
 enum SystemImportUsersGroupMode {
 enum SystemImportUsersGroupMode {
   MULTI
   MULTI
   SINGLE
   SINGLE

+ 1 - 1
server/index.js

@@ -4,9 +4,9 @@
 // ===========================================
 // ===========================================
 
 
 const path = require('path')
 const path = require('path')
-const { nanoid } = require('nanoid')
 const { DateTime } = require('luxon')
 const { DateTime } = require('luxon')
 const semver = require('semver')
 const semver = require('semver')
+const nanoid = require('nanoid').customAlphabet('1234567890abcdef', 10)
 
 
 if (!semver.satisfies(process.version, '>=18')) {
 if (!semver.satisfies(process.version, '>=18')) {
   console.error('ERROR: Node.js 18.x or later required!')
   console.error('ERROR: Node.js 18.x or later required!')

文件差异内容过多而无法显示
+ 0 - 0
ux/public/_assets/icons/fluent-network.svg


+ 7 - 1
ux/src/i18n/locales/en.json

@@ -1534,5 +1534,11 @@
   "admin.scheduler.completedIn": "Completed in {duration}",
   "admin.scheduler.completedIn": "Completed in {duration}",
   "admin.scheduler.pending": "Pending",
   "admin.scheduler.pending": "Pending",
   "admin.scheduler.error": "Error",
   "admin.scheduler.error": "Error",
-  "admin.scheduler.interrupted": "Interrupted"
+  "admin.scheduler.interrupted": "Interrupted",
+  "admin.instances.title": "Instances",
+  "admin.instances.subtitle": "View a list of active instances",
+  "admin.instances.lastSeen": "Last Seen",
+  "admin.instances.firstSeen": "First Seen",
+  "admin.instances.activeListeners": "Active Listeners",
+  "admin.instances.activeConnections": "Active Connections"
 }
 }

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

@@ -146,6 +146,10 @@ q-layout.admin(view='hHh Lpr lff')
           q-item-section(avatar)
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-module.svg')
             q-icon(name='img:/_assets/icons/fluent-module.svg')
           q-item-section {{ t('admin.extensions.title') }}
           q-item-section {{ t('admin.extensions.title') }}
+        q-item(to='/_admin/instances', v-ripple, active-class='bg-primary text-white')
+          q-item-section(avatar)
+            q-icon(name='img:/_assets/icons/fluent-network.svg')
+          q-item-section {{ t('admin.instances.title') }}
         q-item(to='/_admin/mail', v-ripple, active-class='bg-primary text-white')
         q-item(to='/_admin/mail', v-ripple, active-class='bg-primary text-white')
           q-item-section(avatar)
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-message-settings.svg')
             q-icon(name='img:/_assets/icons/fluent-message-settings.svg')

+ 208 - 0
ux/src/pages/AdminInstances.vue

@@ -0,0 +1,208 @@
+<template lang='pug'>
+q-page.admin-terminal
+  .row.q-pa-md.items-center
+    .col-auto
+      img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-network.svg')
+    .col.q-pl-md
+      .text-h5.text-primary.animated.fadeInLeft {{ t('admin.instances.title') }}
+      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.instances.subtitle') }}
+    .col-auto.flex
+      q-btn.q-mr-sm.acrylic-btn(
+        icon='las la-question-circle'
+        flat
+        color='grey'
+        :href='siteStore.docsBase + `/admin/instances`'
+        target='_blank'
+        type='a'
+        )
+      q-btn.q-mr-sm.acrylic-btn(
+        icon='las la-redo-alt'
+        flat
+        color='secondary'
+        :loading='state.loading > 0'
+        @click='load'
+      )
+  q-separator(inset)
+  .q-pa-md.q-gutter-md
+    q-card.shadow-1
+      q-table(
+        :rows='state.instances'
+        :columns='instancesHeaders'
+        row-key='name'
+        flat
+        hide-bottom
+        :rows-per-page-options='[0]'
+        :loading='state.loading > 0'
+        )
+        template(v-slot:body-cell-icon='props')
+          q-td(:props='props')
+            q-icon(name='las la-server', color='positive', size='sm')
+        template(v-slot:body-cell-id='props')
+          q-td(:props='props')
+            strong {{props.value}}
+            div: small.text-grey: strong {{props.row.ip}}
+            div: small.text-grey {{props.row.dbUser}}
+        template(v-slot:body-cell-cons='props')
+          q-td(:props='props')
+            q-chip(
+              icon='las la-plug'
+              square
+              size='md'
+              color='blue'
+              text-color='white'
+              )
+              span.font-robotomono {{ props.value }}
+        template(v-slot:body-cell-subs='props')
+          q-td(:props='props')
+            q-chip(
+              icon='las la-broadcast-tower'
+              square
+              size='md'
+              color='green'
+              text-color='white'
+              )
+              small.text-uppercase {{ props.value }}
+        template(v-slot:body-cell-firstseen='props')
+          q-td(:props='props')
+            span {{props.value}}
+            div: small.text-grey {{humanizeDate(props.row.dbFirstSeen)}}
+        template(v-slot:body-cell-lastseen='props')
+          q-td(:props='props')
+            span {{props.value}}
+            div: small.text-grey {{humanizeDate(props.row.dbLastSeen)}}
+</template>
+
+<script setup>
+import { onMounted, reactive } from 'vue'
+import { useMeta, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import gql from 'graphql-tag'
+import { DateTime, Duration, Interval } from 'luxon'
+
+import { useSiteStore } from 'src/stores/site'
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  title: t('admin.instances.title')
+})
+
+// DATA
+
+const state = reactive({
+  instances: [],
+  loading: 0
+})
+
+const instancesHeaders = [
+  {
+    align: 'center',
+    field: 'id',
+    name: 'icon',
+    sortable: false,
+    style: 'width: 15px; padding-right: 0;'
+  },
+  {
+    label: t('common.field.id'),
+    align: 'left',
+    field: 'id',
+    name: 'id',
+    sortable: true
+  },
+  {
+    label: t('admin.instances.activeConnections'),
+    align: 'left',
+    field: 'activeConnections',
+    name: 'cons',
+    sortable: true
+  },
+  {
+    label: t('admin.instances.activeListeners'),
+    align: 'left',
+    field: 'activeListeners',
+    name: 'subs',
+    sortable: true
+  },
+  {
+    label: t('admin.instances.firstSeen'),
+    align: 'left',
+    field: 'dbFirstSeen',
+    name: 'firstseen',
+    sortable: true,
+    format: v => DateTime.fromISO(v).toRelative()
+  },
+  {
+    label: t('admin.instances.lastSeen'),
+    align: 'left',
+    field: 'dbLastSeen',
+    name: 'lastseen',
+    sortable: true,
+    format: v => DateTime.fromISO(v).toRelative()
+  }
+]
+
+// METHODS
+
+function humanizeDate (val) {
+  return DateTime.fromISO(val).toFormat('fff')
+}
+
+function humanizeDuration (start, end) {
+  const dur = Interval.fromDateTimes(DateTime.fromISO(start), DateTime.fromISO(end))
+    .toDuration(['hours', 'minutes', 'seconds', 'milliseconds'])
+  return Duration.fromObject({
+    ...dur.hours > 0 && { hours: dur.hours },
+    ...dur.minutes > 0 && { minutes: dur.minutes },
+    ...dur.seconds > 0 && { seconds: dur.seconds },
+    ...dur.milliseconds > 0 && { milliseconds: dur.milliseconds }
+  }).toHuman({ unitDisplay: 'narrow', listStyle: 'short' })
+}
+
+async function load () {
+  state.loading++
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query getSystemInstances {
+          systemInstances {
+            id
+            activeConnections
+            activeListeners
+            dbUser
+            dbFirstSeen
+            dbLastSeen
+            ip
+          }
+        }
+      `,
+      fetchPolicy: 'network-only'
+    })
+    state.instances = resp?.data?.systemInstances
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to load list of instances.',
+      caption: err.message
+    })
+  }
+  state.loading--
+}
+
+// MOUNTED
+
+onMounted(() => {
+  load()
+})
+</script>

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

@@ -46,6 +46,7 @@ const routes = [
       // -> System
       // -> System
       { path: 'api', component: () => import('pages/AdminApi.vue') },
       { path: 'api', component: () => import('pages/AdminApi.vue') },
       { path: 'extensions', component: () => import('pages/AdminExtensions.vue') },
       { path: 'extensions', component: () => import('pages/AdminExtensions.vue') },
+      { path: 'instances', component: () => import('pages/AdminInstances.vue') },
       { path: 'mail', component: () => import('pages/AdminMail.vue') },
       { path: 'mail', component: () => import('pages/AdminMail.vue') },
       { path: 'scheduler', component: () => import('pages/AdminScheduler.vue') },
       { path: 'scheduler', component: () => import('pages/AdminScheduler.vue') },
       { path: 'security', component: () => import('pages/AdminSecurity.vue') },
       { path: 'security', component: () => import('pages/AdminSecurity.vue') },

部分文件因为文件数量过多而无法显示