From 249758e3f923a77e1b62d26a47e142c312642a5f Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 4 Apr 2026 05:34:47 -0400 Subject: [PATCH] feat: scheduler admin API --- backend/api/sites.js | 2 +- backend/api/system.js | 218 +++++++++++++++++++++- backend/core/db.js | 30 +-- backend/index.js | 3 +- backend/models/jobs.js | 24 ++- backend/package-lock.json | 6 +- backend/tasks/simple/clean-job-history.js | 13 ++ backend/tasks/simple/update-locales.js | 56 ++++++ backend/worker.js | 5 +- frontend/src/pages/AdminInstances.vue | 18 +- 10 files changed, 328 insertions(+), 47 deletions(-) create mode 100644 backend/tasks/simple/clean-job-history.js create mode 100644 backend/tasks/simple/update-locales.js diff --git a/backend/api/sites.js b/backend/api/sites.js index 58c3083a..1c15f2b6 100644 --- a/backend/api/sites.js +++ b/backend/api/sites.js @@ -468,7 +468,7 @@ async function routes(app) { } } }, - async (req, reply) => { + async () => { return { hello: 'world' } } ) diff --git a/backend/api/system.js b/backend/api/system.js index 08149fc7..1508bc6b 100644 --- a/backend/api/system.js +++ b/backend/api/system.js @@ -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, diff --git a/backend/core/db.js b/backend/core/db.js index 72521526..ad2e43f4 100644 --- a/backend/core/db.js +++ b/backend/core/db.js @@ -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}` diff --git a/backend/index.js b/backend/index.js index 63daeb6e..68226441 100644 --- a/backend/index.js +++ b/backend/index.js @@ -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 = diff --git a/backend/models/jobs.js b/backend/models/jobs.js index ea685d05..05a6edd7 100644 --- a/backend/models/jobs.js +++ b/backend/models/jobs.js @@ -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() diff --git a/backend/package-lock.json b/backend/package-lock.json index ced13125..aaef2bb6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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": { diff --git a/backend/tasks/simple/clean-job-history.js b/backend/tasks/simple/clean-job-history.js new file mode 100644 index 00000000..022a4b9f --- /dev/null +++ b/backend/tasks/simple/clean-job-history.js @@ -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 + } +} diff --git a/backend/tasks/simple/update-locales.js b/backend/tasks/simple/update-locales.js new file mode 100644 index 00000000..bf19af76 --- /dev/null +++ b/backend/tasks/simple/update-locales.js @@ -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 + } +} diff --git a/backend/worker.js b/backend/worker.js index a575855a..c5f61b90 100644 --- a/backend/worker.js +++ b/backend/worker.js @@ -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) { diff --git a/frontend/src/pages/AdminInstances.vue b/frontend/src/pages/AdminInstances.vue index d3c7ce8f..2244b836 100644 --- a/frontend/src/pages/AdminInstances.vue +++ b/frontend/src/pages/AdminInstances.vue @@ -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',