feat: scheduler admin API

pull/7976/head
NGPixel 3 months ago
parent c83aae1b00
commit 249758e3f9
No known key found for this signature in database

@ -468,7 +468,7 @@ async function routes(app) {
}
}
},
async (req, reply) => {
async () => {
return { hello: 'world' }
}
)

@ -14,7 +14,10 @@ import {
/**
* System API Routes
*/
async function routes(app, options) {
async function routes(app) {
/**
* SYSTEM INFO
*/
app.get(
'/info',
{
@ -23,10 +26,82 @@ async function routes(app, options) {
},
schema: {
summary: 'System Info',
tags: ['System']
tags: ['System'],
response: {
200: {
description: 'System Info',
type: 'object',
properties: {
configFile: {
type: 'string'
},
cpuCores: {
type: 'number'
},
currentVersion: {
type: 'string'
},
dbHost: {
type: 'string'
},
groupsTotal: {
type: 'number'
},
hostname: {
type: 'string'
},
httpPort: {
type: 'number'
},
isMailConfigured: {
type: 'boolean'
},
isSchedulerHealthy: {
type: 'boolean'
},
latestVersion: {
type: 'string'
},
latestVersionReleaseDate: {
type: 'string',
format: 'date-time'
},
loginsPastDay: {
type: 'number'
},
nodeVersion: {
type: 'string'
},
operatingSystem: {
type: 'string'
},
pagesTotal: {
type: 'number'
},
platform: {
type: 'string'
},
ramTotal: {
type: 'string'
},
tagsTotal: {
type: 'string'
},
upgradeCapable: {
type: 'boolean'
},
usersTotal: {
type: 'number'
},
workingDirectory: {
type: 'string'
}
}
}
}
}
},
async (request, reply) => {
async () => {
return {
configFile: path.join(process.cwd(), 'config.yml'),
cpuCores: os.cpus().length,
@ -37,9 +112,9 @@ async function routes(app, options) {
hostname: os.hostname(),
httpPort: 0,
isMailConfigured: WIKI.config?.mail?.host?.length > 2,
isSchedulerHealthy: true,
isSchedulerHealthy: true, // TODO:
latestVersion: WIKI.config.update.version,
latestVersionReleaseDate: DateTime.fromISO(WIKI.config.update.versionDate).toJSDate(),
latestVersionReleaseDate: WIKI.config.update.versionDate,
loginsPastDay: await WIKI.db.$count(
usersTable,
gte(usersTable.lastLoginAt, sql`NOW() - INTERVAL '1 DAY'`)
@ -57,19 +132,126 @@ async function routes(app, options) {
}
)
/**
* SYSTEM FLAGS
*/
app.get(
'/flags',
{
schema: {
summary: 'System Flags',
tags: ['System']
tags: ['System'],
response: {
200: {
description: 'System Flags',
type: 'object',
properties: {
experimental: {
type: 'boolean'
},
authDebug: {
type: 'boolean'
},
sqlLog: {
type: 'boolean'
}
}
}
}
}
},
async (request, reply) => {
async () => {
return WIKI.config.flags
}
)
/**
* LIST SYSTEM INSTANCES
*/
app.get(
'/instances',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List System Instances',
tags: ['System'],
response: {
200: {
description: 'List of all system instances',
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'string'
},
activeConnections: {
type: 'number'
},
activeListeners: {
type: 'number'
},
dbUser: {
type: 'string'
},
dbFirstSeen: {
type: 'string',
format: 'date-time'
},
dbLastSeen: {
type: 'string',
format: 'date-time'
},
ip: {
type: 'string'
}
}
}
}
}
}
},
async () => {
const instRaw = await WIKI.db.execute(
sql`SELECT usename, client_addr, application_name, backend_start, state_change FROM pg_stat_activity WHERE datname = ${WIKI.dbManager.dbName} AND application_name LIKE 'Wiki.js%'`
)
const insts = {}
for (const inst of instRaw.rows) {
const instId = inst.application_name.substring(10, 20)
const conType = [':MAIN', ':WORKER'].some((ct) => inst.application_name.endsWith(ct))
? 'main'
: 'sub'
inst.backend_start = DateTime.fromSQL(inst.backend_start).toISO()
inst.state_change = DateTime.fromSQL(inst.state_change).toISO()
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 Object.values(insts)
}
)
/**
* CHECK FOR UPDATE
*/
app.post(
'/checkForUpdate',
{
@ -78,10 +260,28 @@ async function routes(app, options) {
},
schema: {
summary: 'Check for Updates',
tags: ['System']
tags: ['System'],
response: {
200: {
description: 'Update Info',
type: 'object',
properties: {
current: {
type: 'string'
},
latest: {
type: 'string'
},
latestDate: {
type: 'string',
format: 'date-time'
}
}
}
}
}
},
async (request, reply) => {
async () => {
const renderJob = await WIKI.scheduler.addJob({
task: 'checkVersion',
maxRetries: 0,

@ -5,6 +5,7 @@ import { setTimeout } from 'node:timers/promises'
import { drizzle } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { Pool } from 'pg'
import { parse } from 'pg-connection-string'
import semver from 'semver'
import { relations } from '../db/relations.js'
@ -19,6 +20,7 @@ export default {
pool: null,
pubsubClient: null,
config: null,
dbName: null,
VERSION: null,
LEGACY: false,
onReady: createDeferred(),
@ -31,17 +33,21 @@ export default {
// Fetch DB Config
this.config = process.env.DATABASE_URL
? {
connectionString: process.env.DATABASE_URL
}
: {
host: WIKI.config.db.host.toString(),
user: WIKI.config.db.user.toString(),
password: WIKI.config.db.pass.toString(),
database: WIKI.config.db.db.toString(),
port: WIKI.config.db.port
}
if (process.env.DATABASE_URL) {
this.config = {
connectionString: process.env.DATABASE_URL
}
this.dbName = parse(process.env.DATABASE_URL).database
} else {
this.config = {
host: WIKI.config.db.host.toString(),
user: WIKI.config.db.user.toString(),
password: WIKI.config.db.pass.toString(),
database: WIKI.config.db.db.toString(),
port: WIKI.config.db.port
}
this.dbName = this.config.database
}
// Handle SSL Options
@ -91,7 +97,7 @@ export default {
// Initialize Postgres Pool
this.pool = new Pool({
application_name: `Wiki.js - ${WIKI.INSTANCE_ID}:MAIN`,
application_name: `Wiki.js - ${WIKI.INSTANCE_ID}:${workerMode ? 'WORKER' : 'MAIN'}`,
...this.config,
...(workerMode ? { min: 0, max: 1 } : WIKI.config.pool),
options: `-c search_path=${WIKI.config.db.schema}`

@ -8,6 +8,7 @@ import path from 'node:path'
import { DateTime } from 'luxon'
import semver from 'semver'
import { customAlphabet } from 'nanoid'
import { uniq } from 'es-toolkit/array'
import fastify from 'fastify'
import fastifyCompress from '@fastify/compress'
@ -343,7 +344,7 @@ async function initHTTPServer() {
}
nestedPermissions.push('`manage:system`')
transformedSchema.description =
`${currentDescription}\n\n**Required Permissions:** ${nestedPermissions.join(' or ')}`.trim()
`${currentDescription}\n\n**Required Permissions:** ${uniq(nestedPermissions).join(' or ')}`.trim()
transformedSchema['x-permissions'] = permissions
} else {
transformedSchema.description =

@ -1,5 +1,10 @@
import { DateTime } from 'luxon'
import { jobSchedule as jobScheduleTable, jobLock as jobLockTable } from '../db/schema.js'
import {
jobSchedule as jobScheduleTable,
jobLock as jobLockTable,
jobHistory as jobHistoryTable
} from '../db/schema.js'
import { and, eq, lte, not } from 'drizzle-orm'
/**
* Jobs model
@ -40,6 +45,23 @@ class Jobs {
lastCheckedAt: DateTime.utc().minus({ hours: 1 }).toISO()
})
}
/**
* Purge old job history
*/
async cleanHistory() {
await WIKI.db
.delete(jobHistoryTable)
.where(
and(
not(eq(jobHistoryTable.state, 'active')),
lte(
jobHistoryTable.startedAt,
DateTime.utc().minus({ seconds: WIKI.config.scheduler.historyExpiration }).toJSDate()
)
)
)
}
}
export const jobs = new Jobs()

@ -4366,9 +4366,9 @@
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz",
"integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==",
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT"
},
"node_modules/pg-int8": {

@ -0,0 +1,13 @@
export async function task() {
WIKI.logger.info('Cleaning scheduler job history...')
try {
await WIKI.models.jobs.cleanHistory()
WIKI.logger.info('Cleaned scheduler job history: [ COMPLETED ]')
} catch (err) {
WIKI.logger.error('Cleaning scheduler job history: [ FAILED ]')
WIKI.logger.error(err.message)
throw err
}
}

@ -0,0 +1,56 @@
import { setTimeout } from 'node:timers/promises'
export async function task() {
if (WIKI.config.update?.locales === false) {
return
}
WIKI.logger.info('Fetching latest localization data...')
try {
const metadata = await fetch(
'https://github.com/requarks/wiki-locales/raw/main/locales/metadata.json'
).then((r) => r.json())
for (const lang of metadata.languages) {
// -> Build filename
const langFilenameParts = [lang.language]
if (lang.region) {
langFilenameParts.push(lang.region)
}
if (lang.script) {
langFilenameParts.push(lang.script)
}
const langFilename = langFilenameParts.join('-')
WIKI.logger.debug(`Fetching updates for language ${langFilename}...`)
// TODO: Adapt for v3
// const strings = await fetch(`https://github.com/requarks/wiki-locales/raw/main/locales/${langFilename}.json`).then(r => r.json())
// if (strings) {
// await WIKI.db.knex('locales').insert({
// code: langFilename,
// name: lang.name,
// nativeName: lang.localizedName,
// language: lang.language,
// region: lang.region,
// script: lang.script,
// isRTL: lang.isRtl,
// strings
// }).onConflict('code').merge({
// strings,
// updatedAt: new Date()
// })
// }
WIKI.logger.debug(`Updated strings for language ${langFilename}.`)
await setTimeout(100)
}
WIKI.logger.info('Fetched latest localization data: [ COMPLETED ]')
} catch (err) {
WIKI.logger.error('Fetching latest localization data: [ FAILED ]')
WIKI.logger.error(err.message)
throw err
}
}

@ -3,7 +3,7 @@ import { kebabCase } from 'es-toolkit/string'
import path from 'node:path'
import configSvc from './core/config.js'
import logger from './core/logger.js'
import db from './core/db.js'
import dbManager from './core/db.js'
// ----------------------------------------
// Init Minimal Core
@ -20,11 +20,10 @@ const WIKI = {
return true
}
WIKI.db = await db.init(true)
WIKI.db = await dbManager.init(true)
try {
await WIKI.configSvc.loadFromDb()
await WIKI.configSvc.applyFlags()
} catch (err) {
WIKI.logger.error('Database Initialization Error: ' + err.message)
if (WIKI.IS_DEBUG) {

@ -177,23 +177,7 @@ function humanizeDuration (start, end) {
async function load () {
state.loading++
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getSystemInstances {
systemInstances {
id
activeConnections
activeListeners
dbUser
dbFirstSeen
dbLastSeen
ip
}
}
`,
fetchPolicy: 'network-only'
})
state.instances = resp?.data?.systemInstances
state.instances = await API_CLIENT.get('system/instances').json()
} catch (err) {
$q.notify({
type: 'negative',

Loading…
Cancel
Save