From 45b5bd5cdcd99d83c13a9751151df7cb3daffb78 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 25 Jul 2026 22:05:29 +0000 Subject: [PATCH] refactor: wire admin scheduler + metrics views --- backend/api/index.ts | 2 + backend/api/scheduler.ts | 296 ++++++++++++++++++++++++++ backend/api/schemas/scheduler.ts | 155 ++++++++++++++ backend/api/system.ts | 104 ++++++++- backend/core/scheduler.ts | 8 +- backend/locales/en.json | 10 + backend/models/jobs.ts | 157 +++++++++++++- frontend/src/pages/AdminMetrics.vue | 69 +++--- frontend/src/pages/AdminScheduler.vue | 290 +++++++++++++------------ frontend/src/stores/admin.js | 3 +- 10 files changed, 913 insertions(+), 181 deletions(-) create mode 100644 backend/api/scheduler.ts create mode 100644 backend/api/schemas/scheduler.ts diff --git a/backend/api/index.ts b/backend/api/index.ts index 5775dc714..67d2007b7 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -8,6 +8,7 @@ async function routes(app: FastifyInstance) { await import('./schemas/block.ts').then((m) => m.registerSchemas(app)) await import('./schemas/group.ts').then((m) => m.registerSchemas(app)) await import('./schemas/mail.ts').then((m) => m.registerSchemas(app)) + await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app)) await import('./schemas/site.ts').then((m) => m.registerSchemas(app)) await import('./schemas/user.ts').then((m) => m.registerSchemas(app)) @@ -18,6 +19,7 @@ async function routes(app: FastifyInstance) { app.register(import('./locales.ts'), { prefix: '/locales' }) app.register(import('./mail.ts'), { prefix: '/mail' }) app.register(import('./pages.ts')) + app.register(import('./scheduler.ts'), { prefix: '/scheduler' }) app.register(import('./sites.ts'), { prefix: '/sites' }) app.register(import('./system.ts'), { prefix: '/system' }) app.register(import('./users.ts'), { prefix: '/users' }) diff --git a/backend/api/scheduler.ts b/backend/api/scheduler.ts new file mode 100644 index 000000000..d8e2757f7 --- /dev/null +++ b/backend/api/scheduler.ts @@ -0,0 +1,296 @@ +import type { FastifyInstance } from 'fastify' +import { JOB_STATES, type JobState } from '../models/jobs.ts' + +/** + * Scheduler API Routes + */ +async function routes(app: FastifyInstance) { + /** + * LIST SCHEDULED TASKS + */ + app.get( + '/schedule', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'List the cron schedule', + description: + 'The tasks the scheduler runs automatically. These are definitions, not executions — the jobs they produce show up under upcoming and then in the history.', + tags: ['Scheduler'], + response: { + 200: { + description: 'List of scheduled tasks', + type: 'array', + items: { $ref: 'SchedulerTask#' } + } + } + } + }, + async () => { + return WIKI.models.jobs.getSchedule() + } + ) + + /** + * RUN A SCHEDULED TASK NOW + */ + app.post<{ Params: { scheduleId: string } }>( + '/schedule/:scheduleId/run', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Run a scheduled task now', + description: + 'Queues the task immediately, without waiting for its cron expression and without disturbing the planned iterations. The run is recorded in the history like any other job.', + tags: ['Scheduler'], + params: { + type: 'object', + properties: { + scheduleId: { + type: 'string', + format: 'uuid' + } + }, + required: ['scheduleId'] + }, + response: { + 200: { + description: 'Task queued successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + id: { + type: 'string', + format: 'uuid', + description: 'The ID of the queued job.' + } + } + } + } + } + }, + async (req, reply) => { + const entry = await WIKI.models.jobs.getScheduleEntry(req.params.scheduleId) + if (!entry) { + return reply.notFound('Scheduled task does not exist.') + } + + const id = await WIKI.models.jobs.runScheduledTask(entry) + if (!id) { + return reply.internalServerError('The scheduler could not queue the job.') + } + + return { + ok: true, + message: 'Task queued successfully.', + id + } + } + ) + + /** + * LIST UPCOMING JOBS + */ + app.get( + '/upcoming', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'List the pending job queue', + description: + 'Jobs waiting to be picked up, soonest first. A job with no `waitUntil` is eligible immediately.', + tags: ['Scheduler'], + response: { + 200: { + description: 'List of upcoming jobs', + type: 'array', + items: { $ref: 'SchedulerUpcomingJob#' } + } + } + } + }, + async () => { + return WIKI.models.jobs.getUpcoming() + } + ) + + /** + * CANCEL UPCOMING JOB + */ + app.delete<{ Params: { jobId: string } }>( + '/upcoming/:jobId', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Cancel a pending job', + description: + 'Removes the job from the queue. A job that an instance has already picked up cannot be cancelled and answers 404, as it is no longer pending.', + tags: ['Scheduler'], + params: { + type: 'object', + properties: { + jobId: { + type: 'string', + format: 'uuid' + } + }, + required: ['jobId'] + }, + response: { + 204: { + description: 'Job cancelled successfully' + } + } + } + }, + async (req, reply) => { + const cancelled = await WIKI.models.jobs.cancelUpcoming(req.params.jobId) + if (!cancelled) { + return reply.notFound('No pending job with this ID.') + } + return reply.code(204).send() + } + ) + + /** + * LIST JOB HISTORY + */ + app.get<{ Querystring: { states?: JobState[]; limit?: number } }>( + '/jobs', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'List job execution history', + description: + 'Past and running jobs, most recently started first. Older entries are purged by the `cleanJobHistory` task.', + tags: ['Scheduler'], + querystring: { + type: 'object', + properties: { + states: { + type: 'array', + description: 'Keep only jobs in these states. All states when omitted.', + items: { + type: 'string', + enum: JOB_STATES + } + }, + limit: { type: 'integer', minimum: 1, maximum: 500, default: 100 } + } + }, + response: { + 200: { + description: 'List of jobs', + type: 'object', + properties: { + total: { + type: 'integer', + description: + 'How many jobs match the requested states, which can exceed the number returned.' + }, + limit: { + type: 'integer' + }, + jobs: { + type: 'array', + items: { $ref: 'SchedulerJob#' } + } + } + } + } + } + }, + async (req) => { + const limit = req.query.limit ?? 100 + const { total, jobs } = await WIKI.models.jobs.getHistory({ + states: req.query.states ?? [], + limit + }) + return { total, limit, jobs } + } + ) + + /** + * RETRY JOB + */ + app.post<{ Params: { jobId: string } }>( + '/jobs/:jobId/retry', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Run a past job again', + description: + 'Queues a new job with the same task and payload. The original history entry is left as it is, and the new run is recorded separately with a full retry budget.', + tags: ['Scheduler'], + params: { + type: 'object', + properties: { + jobId: { + type: 'string', + format: 'uuid' + } + }, + required: ['jobId'] + }, + response: { + 200: { + description: 'Job queued successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + id: { + type: 'string', + format: 'uuid', + description: 'The ID of the newly queued job, not the one it was created from.' + } + } + } + } + } + }, + async (req, reply) => { + const entry = await WIKI.models.jobs.getHistoryEntry(req.params.jobId) + if (!entry) { + return reply.notFound('Job does not exist.') + } + if (entry.state === 'active') { + return reply.conflict('This job is still running.') + } + + const id = await WIKI.models.jobs.retryJob(entry) + if (!id) { + return reply.internalServerError('The scheduler could not queue the job.') + } + + return { + ok: true, + message: 'Job queued successfully.', + id + } + } + ) +} + +export default routes diff --git a/backend/api/schemas/scheduler.ts b/backend/api/schemas/scheduler.ts new file mode 100644 index 000000000..225c1a626 --- /dev/null +++ b/backend/api/schemas/scheduler.ts @@ -0,0 +1,155 @@ +import type { FastifyInstance } from 'fastify' +import { JOB_STATES } from '../../models/jobs.ts' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * SCHEDULER TASK - A cron entry, i.e. a task that runs automatically + */ + app.addSchema({ + $id: 'SchedulerTask', + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid' + }, + task: { + type: 'string', + description: 'Task name, matching a file under `tasks/simple/` or `tasks/workers/`.' + }, + cron: { + type: 'string', + description: 'Cron expression, evaluated in UTC.' + }, + type: { + type: 'string', + description: 'Where the entry comes from, e.g. `system`.' + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time' + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time' + } + } + }) + + /** + * SCHEDULER UPCOMING JOB - A job waiting in the queue + */ + app.addSchema({ + $id: 'SchedulerUpcomingJob', + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid' + }, + task: { + type: 'string' + }, + useWorker: { + type: 'boolean', + description: 'True when the task runs in a worker thread rather than in-process.' + }, + retries: { + type: 'integer', + description: 'How many attempts have already failed. Zero on a first attempt.' + }, + maxRetries: { + type: 'integer' + }, + waitUntil: { + // -> Jobs meant to run as soon as a worker is free have no date at all + type: 'string', + nullable: true, + format: 'date-time', + description: 'RFC 3339 Date Time, or null to run at the next opportunity' + }, + isScheduled: { + type: 'boolean', + description: 'True when the job was created from a cron entry rather than on demand.' + }, + createdBy: { + type: 'string', + nullable: true, + description: 'ID of the instance that queued the job.' + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time' + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time' + } + } + }) + + /** + * SCHEDULER JOB - One execution, as recorded in the job history + */ + app.addSchema({ + $id: 'SchedulerJob', + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid' + }, + task: { + type: 'string' + }, + state: { + type: 'string', + enum: JOB_STATES, + description: + '`active` while running, `interrupted` when the run was cut short rather than failing on its own.' + }, + useWorker: { + type: 'boolean' + }, + wasScheduled: { + type: 'boolean' + }, + attempt: { + type: 'integer', + description: 'Which attempt this execution was, starting at 1.' + }, + maxRetries: { + type: 'integer' + }, + lastErrorMessage: { + type: 'string', + nullable: true + }, + executedBy: { + type: 'string', + nullable: true, + description: 'ID of the instance that ran the job.' + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time — when the job was queued' + }, + startedAt: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time' + }, + completedAt: { + type: 'string', + nullable: true, + format: 'date-time', + description: 'RFC 3339 Date Time, or null while the job has not finished' + } + } + }) +} diff --git a/backend/api/system.ts b/backend/api/system.ts index 6dca0b877..4a8f86aa7 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -56,8 +56,14 @@ async function routes(app: FastifyInstance) { isMailConfigured: { type: 'boolean' }, + isMetricsEnabled: { + type: 'boolean', + description: 'Whether the Prometheus metrics endpoint is turned on.' + }, isSchedulerHealthy: { - type: 'boolean' + type: 'boolean', + description: + 'False when no instance has refreshed the scheduler cron lock recently, i.e. scheduled jobs are no longer being queued.' }, latestVersion: { type: 'string' @@ -112,7 +118,8 @@ async function routes(app: FastifyInstance) { hostname: os.hostname(), httpPort: 0, isMailConfigured: WIKI.config?.mail?.host?.length > 2, - isSchedulerHealthy: true, // TODO: + isMetricsEnabled: WIKI.config.metrics.isEnabled === true, + isSchedulerHealthy: await WIKI.models.jobs.isHealthy(), latestVersion: WIKI.config.update.version, latestVersionReleaseDate: WIKI.config.update.versionDate, loginsPastDay: await WIKI.db.$count( @@ -165,6 +172,99 @@ async function routes(app: FastifyInstance) { } ) + /** + * GET METRICS ENDPOINT STATE + */ + app.get( + '/metrics', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Get the metrics endpoint state', + description: + 'Whether the Prometheus metrics endpoint is turned on. The endpoint itself is not implemented yet — see the description of the PUT counterpart.', + tags: ['System'], + response: { + 200: { + description: 'Metrics endpoint state', + type: 'object', + properties: { + isEnabled: { + type: 'boolean' + } + } + } + } + } + }, + async () => { + return { isEnabled: WIKI.config.metrics.isEnabled === true } + } + ) + + /** + * SET METRICS ENDPOINT STATE + */ + app.put<{ Body: { isEnabled: boolean } }>( + '/metrics', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Turn the metrics endpoint on or off', + description: + 'Stores the state and nothing more, for now: the `/metrics` endpoint it governs is not implemented, and its documented `read:metrics` bearer authentication depends on API keys, which are not implemented either.', + tags: ['System'], + body: { + type: 'object', + required: ['isEnabled'], + properties: { + isEnabled: { + type: 'boolean' + } + } + }, + response: { + 200: { + description: 'Metrics endpoint state updated successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + isEnabled: { + type: 'boolean' + } + } + } + } + } + }, + async (req, reply) => { + const previousConfig = WIKI.config.metrics + WIKI.config.metrics = { ...previousConfig, isEnabled: req.body.isEnabled } + + if (!(await WIKI.configSvc.saveToDb(['metrics']))) { + WIKI.config.metrics = previousConfig + return reply.internalServerError('Failed to save the metrics endpoint state.') + } + + return { + ok: true, + message: req.body.isEnabled + ? 'Metrics endpoint enabled successfully.' + : 'Metrics endpoint disabled successfully.', + isEnabled: req.body.isEnabled + } + } + ) + /** * LIST SYSTEM INSTANCES */ diff --git a/backend/core/scheduler.ts b/backend/core/scheduler.ts index b7ee21773..f163ea7a7 100644 --- a/backend/core/scheduler.ts +++ b/backend/core/scheduler.ts @@ -301,13 +301,17 @@ export default { } catch (err: any) { WIKI.logger.warn(err) if (jobIds && jobIds.length > 0) { - WIKI.db + // -> The filter must name the table being updated: `jobs.id` here produced + // `UPDATE "jobHistory" ... WHERE "jobs"."id" IN (...)`, which postgres rejects with + // "missing FROM-clause entry", and the statement was not awaited so the rejection was + // lost. Interrupted jobs were therefore never recorded as such. + await WIKI.db .update(jobHistoryTable) .set({ state: 'interrupted', lastErrorMessage: err.message }) - .where(inArray(jobsTable.id, jobIds)) + .where(inArray(jobHistoryTable.id, jobIds)) } } }, diff --git a/backend/locales/en.json b/backend/locales/en.json index 3d7e0d343..047c534c7 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -497,11 +497,14 @@ "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.loadFailed": "Failed to load the metrics endpoint state.", + "admin.metrics.notImplemented": "The endpoint itself is not available yet: this setting is saved, but nothing serves {endpoint} so far.", "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.metrics.toggleStateFailed": "Failed to switch the metrics endpoint state.", "admin.nav.modules": "Modules", "admin.nav.site": "Site", "admin.nav.system": "System", @@ -550,6 +553,7 @@ "admin.scheduler.activeNone": "There are no active jobs at the moment.", "admin.scheduler.attempt": "Attempt", "admin.scheduler.cancelJob": "Cancel Job", + "admin.scheduler.cancelJobFailed": "Failed to cancel the job.", "admin.scheduler.cancelJobSuccess": "Job cancelled successfully.", "admin.scheduler.completed": "Completed", "admin.scheduler.completedIn": "Completed in {duration}", @@ -560,11 +564,17 @@ "admin.scheduler.error": "Error", "admin.scheduler.failed": "Failed", "admin.scheduler.failedNone": "There are no recently failed job to display.", + "admin.scheduler.historyCapped": "Showing the {shown} most recent of {total} jobs.", "admin.scheduler.interrupted": "Interrupted", + "admin.scheduler.loadFailed": "Failed to load jobs.", "admin.scheduler.pending": "Pending", "admin.scheduler.result": "Result", "admin.scheduler.retryJob": "Retry Job", + "admin.scheduler.retryJobFailed": "Failed to retry the job.", "admin.scheduler.retryJobSuccess": "Job has been rescheduled and will execute shortly.", + "admin.scheduler.runNow": "Run Now", + "admin.scheduler.runNowFailed": "Failed to queue the task.", + "admin.scheduler.runNowSuccess": "{task} has been queued and will run shortly.", "admin.scheduler.schedule": "Schedule", "admin.scheduler.scheduled": "Scheduled", "admin.scheduler.scheduledNone": "There are no scheduled jobs at the moment.", diff --git a/backend/models/jobs.ts b/backend/models/jobs.ts index 0f0aa7d56..031374a07 100644 --- a/backend/models/jobs.ts +++ b/backend/models/jobs.ts @@ -1,12 +1,27 @@ import { + jobs as jobsTable, jobSchedule as jobScheduleTable, jobLock as jobLockTable, jobHistory as jobHistoryTable } from '../db/schema.ts' -import { and, eq, lte, not } from 'drizzle-orm' +import { and, count, desc, eq, inArray, lte, not, sql } from 'drizzle-orm' + +/** The states a job can be in once it has been picked up for execution. */ +export const JOB_STATES = ['active', 'completed', 'failed', 'interrupted'] as const +export type JobState = (typeof JOB_STATES)[number] + +/** One page of job history, with the total matching the requested states. */ +export interface JobHistoryPage { + total: number + jobs: (typeof jobHistoryTable.$inferSelect)[] +} /** * Jobs model + * + * Three tables back the scheduler, and the admin area shows all three: `jobSchedule` holds the cron + * definitions, `jobs` is the pending queue, and `jobHistory` records every execution. A job moves + * from `jobs` to `jobHistory` when a worker picks it up — see `core/scheduler.ts`. */ class Jobs { /** @@ -50,6 +65,146 @@ class Jobs { }) } + /** + * Whether the scheduler is keeping up with its cron duties. + * + * Exactly one instance holds the `cron` lock at a time and refreshes it as it queues the next + * batch of scheduled jobs, so a stale timestamp means no instance is running that check any more. + * The lock is only re-acquired once it is 5 minutes old, and the check itself runs on an interval, + * so the threshold has to be a comfortable multiple of that to avoid crying wolf. + */ + async isHealthy(): Promise { + const results = await WIKI.db + .select({ lastCheckedAt: jobLockTable.lastCheckedAt }) + .from(jobLockTable) + .where(eq(jobLockTable.key, 'cron')) + .limit(1) + const lastCheckedAt = results[0]?.lastCheckedAt + if (!lastCheckedAt) { + return false + } + return ( + Temporal.Instant.compare( + lastCheckedAt.toTemporalInstant(), + Temporal.Now.instant().subtract({ minutes: 15 }) + ) > 0 + ) + } + + /** + * The cron schedule: which tasks run automatically and how often + */ + async getSchedule() { + return WIKI.db.select().from(jobScheduleTable).orderBy(jobScheduleTable.task) + } + + /** + * A single cron entry, or null if there is no such entry + */ + async getScheduleEntry(id: string) { + const results = await WIKI.db + .select() + .from(jobScheduleTable) + .where(eq(jobScheduleTable.id, id)) + .limit(1) + return results[0] ?? null + } + + /** + * Queue a cron entry's task to run at the next opportunity. + * + * The job is deliberately *not* flagged as scheduled: it is an on-demand run, so it must not be + * mistaken for one of the planned iterations that `scheduler.addScheduled()` reconciles. + * + * @returns The new job's ID, or null if the scheduler refused it + */ + async runScheduledTask(entry: typeof jobScheduleTable.$inferSelect): Promise { + const added = await WIKI.scheduler.addJob({ + task: entry.task, + payload: entry.payload ?? {} + }) + return added?.id ?? null + } + + /** + * The pending queue, soonest first. Jobs with no `waitUntil` are eligible right away, so they + * come before any dated ones. + */ + async getUpcoming() { + return WIKI.db + .select() + .from(jobsTable) + .orderBy(sql`${jobsTable.waitUntil} ASC NULLS FIRST`, jobsTable.createdAt) + } + + /** + * Job execution history, most recently started first. + * + * @param states Keep only these states; all of them when empty + * @param limit Caps the rows returned — `total` still counts every match, so a caller can tell + * that it is looking at a truncated view + */ + async getHistory({ + states = [], + limit = 100 + }: { states?: JobState[]; limit?: number } = {}): Promise { + const where = states.length > 0 ? inArray(jobHistoryTable.state, states) : undefined + const totals = await WIKI.db.select({ total: count() }).from(jobHistoryTable).where(where) + const jobs = await WIKI.db + .select() + .from(jobHistoryTable) + .where(where) + .orderBy(desc(jobHistoryTable.startedAt)) + .limit(limit) + + return { + total: totals[0]?.total ?? 0, + jobs + } + } + + /** + * A single history entry, or null if no such job ever ran + */ + async getHistoryEntry(id: string) { + const results = await WIKI.db + .select() + .from(jobHistoryTable) + .where(eq(jobHistoryTable.id, id)) + .limit(1) + return results[0] ?? null + } + + /** + * Drop a job from the pending queue. + * + * Only queued jobs can be cancelled: once an instance has picked one up it is gone from `jobs` + * and already running. + * + * @returns Whether a queued job was removed + */ + async cancelUpcoming(id: string): Promise { + const result = await WIKI.db.delete(jobsTable).where(eq(jobsTable.id, id)) + return (result.rowCount ?? 0) > 0 + } + + /** + * Queue a fresh run of a past job. + * + * The original history entry is left alone and the new run gets its own entry with a full retry + * budget — history is a log of executions, not a mutable job record. + * + * @returns The new job's ID, or null if the scheduler refused it + */ + async retryJob(entry: typeof jobHistoryTable.$inferSelect): Promise { + const added = await WIKI.scheduler.addJob({ + task: entry.task, + payload: entry.payload ?? {}, + maxRetries: entry.maxRetries + }) + return added?.id ?? null + } + /** * Purge old job history */ diff --git a/frontend/src/pages/AdminMetrics.vue b/frontend/src/pages/AdminMetrics.vue index 6cfb36a49..1e3bda470 100644 --- a/frontend/src/pages/AdminMetrics.vue +++ b/frontend/src/pages/AdminMetrics.vue @@ -58,6 +58,11 @@ q-page.admin-api template(#endpoint) strong.font-robotomono /metrics .text-caption {{ t('admin.metrics.endpointWarning') }} + //- The state is stored, but no route serves it yet — say so rather than let the card + //- above read as a promise + i18n-t.text-caption.text-orange(tag='div', keypath='admin.metrics.notImplemented', scope='global') + template(#endpoint) + strong.font-robotomono /metrics q-card.rounded-borders.q-mt-md( flat :class='$q.dark.isActive ? `bg-dark-5 text-white` : `bg-grey-3 text-dark`' @@ -78,10 +83,9 @@ q-page.admin-api