diff options
36 files changed, 2447 insertions, 211 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 87dc28d6c0..0524d3c5bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 2025.4.1 ### General +- Feat: bull-boardに代わるジョブキューの管理ツールが実装されました - Enhance: チャットの新規メッセージをプッシュ通知するように ### Client @@ -18,6 +19,7 @@ - Fix: アカウントの移行時にアンテナのフィルターのユーザが更新されない問題を修正 #15843 ### Server +- Enhance: ジョブキューの成功/失敗したジョブも一定数・一定期間保存するようにし、後から問題を調査することを容易に - Enhance: フォローしているユーザーならフォロワー限定投稿のノートでもアンテナで検知できるように (Cherry-picked from https://github.com/yojo-art/cherrypick/pull/568 and https://github.com/team-shahu/misskey/pull/38) - Fix: システムアカウントの名前がサーバー名と同期されない問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index 97e66be6d7..cb632dfb25 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5398,6 +5398,10 @@ export interface Locale extends ILocale { * デッキへ戻る */ "goToDeck": string; + /** + * 連合ジョブ + */ + "federationJobs": string; "_chat": { /** * まだメッセージはありません diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1801c8e15b..fb2590d8fd 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1345,6 +1345,7 @@ embed: "埋め込み" settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)" readonly: "読み取り専用" goToDeck: "デッキへ戻る" +federationJobs: "連合ジョブ" _chat: noMessagesYet: "まだメッセージはありません" diff --git a/packages/backend/package.json b/packages/backend/package.json index 5e8529c422..e8b3d74aa7 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -93,6 +93,7 @@ "@swc/cli": "0.6.0", "@swc/core": "1.11.18", "@twemoji/parser": "15.1.1", + "@types/redis-info": "3.0.3", "accepts": "1.3.8", "ajv": "8.17.1", "archiver": "7.0.1", @@ -159,6 +160,7 @@ "random-seed": "0.3.0", "ratelimiter": "3.4.1", "re2": "1.21.4", + "redis-info": "3.1.0", "redis-lock": "0.1.4", "reflect-metadata": "0.2.2", "rename": "1.0.4", diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 1ddb2b173d..469426f87e 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -576,7 +576,14 @@ export class NoteCreateService implements OnApplicationShutdown { noteId: note.id, }, { delay, - removeOnComplete: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index eb02355625..a1e806816b 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; +import { MetricsTime, type JobType } from 'bullmq'; +import { parse as parseRedisInfo } from 'redis-info'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; @@ -69,50 +71,58 @@ export class QueueService { this.systemQueue.add('tickCharts', { }, { repeat: { pattern: '55 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('resyncCharts', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('cleanCharts', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('aggregateRetention', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('clean', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('checkExpiredMutings', { }, { repeat: { pattern: '*/5 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('bakeBufferedReactions', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('checkModeratorsActivity', { }, { // 毎時30分に起動 repeat: { pattern: '30 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); } @@ -134,13 +144,21 @@ export class QueueService { isSharedInbox, }; - return this.deliverQueue.add(to, data, { + const label = to.replace('https://', '').replace('/inbox', ''); + + return this.deliverQueue.add(label, data, { attempts: this.config.deliverJobMaxAttempts ?? 12, backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -162,12 +180,18 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }; await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({ - name: d[0], + name: d[0].replace('https://', '').replace('/inbox', ''), data: { user, content: contentBody, @@ -188,13 +212,21 @@ export class QueueService { signature, }; - return this.inboxQueue.add('', data, { + const label = (activity.id ?? '').replace('https://', '').replace('/activity', ''); + + return this.inboxQueue.add(label, data, { attempts: this.config.inboxJobMaxAttempts ?? 8, backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -203,8 +235,14 @@ export class QueueService { return this.dbQueue.add('deleteDriveFiles', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -213,8 +251,14 @@ export class QueueService { return this.dbQueue.add('exportCustomEmojis', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -223,8 +267,14 @@ export class QueueService { return this.dbQueue.add('exportNotes', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -233,8 +283,14 @@ export class QueueService { return this.dbQueue.add('exportClips', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -243,8 +299,14 @@ export class QueueService { return this.dbQueue.add('exportFavorites', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -255,8 +317,14 @@ export class QueueService { excludeMuting, excludeInactive, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -265,8 +333,14 @@ export class QueueService { return this.dbQueue.add('exportMuting', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -275,8 +349,14 @@ export class QueueService { return this.dbQueue.add('exportBlocking', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -285,8 +365,14 @@ export class QueueService { return this.dbQueue.add('exportUserLists', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -295,8 +381,14 @@ export class QueueService { return this.dbQueue.add('exportAntennas', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -307,8 +399,14 @@ export class QueueService { fileId: fileId, withReplies, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -324,8 +422,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -335,8 +439,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -356,8 +466,14 @@ export class QueueService { name, data, opts: { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }, }; } @@ -368,8 +484,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -379,8 +501,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -390,8 +518,14 @@ export class QueueService { user: { id: user.id }, antenna, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -401,8 +535,14 @@ export class QueueService { user: { id: user.id }, soft: opts.soft, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -452,8 +592,14 @@ export class QueueService { withReplies: data.withReplies, }, opts: { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, ...opts, }, }; @@ -464,16 +610,28 @@ export class QueueService { return this.objectStorageQueue.add('deleteFile', { key: key, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @bindThis public createCleanRemoteFilesJob() { return this.objectStorageQueue.add('cleanRemoteFiles', {}, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -504,8 +662,14 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -535,13 +699,19 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @bindThis - private getQueue(type: typeof QUEUE_TYPES[number]) { + private getQueue(type: typeof QUEUE_TYPES[number]): Bull.Queue { switch (type) { case 'system': return this.systemQueue; case 'endedPollNotification': return this.endedPollNotificationQueue; @@ -557,19 +727,173 @@ export class QueueService { } @bindThis - public clearQueue(queueType: typeof QUEUE_TYPES[number], state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed') { + public async queueClear(queueType: typeof QUEUE_TYPES[number], state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed') { const queue = this.getQueue(queueType); if (state === '*') { - queue.clean(0, 0, 'completed'); - queue.clean(0, 0, 'wait'); - queue.clean(0, 0, 'active'); - queue.clean(0, 0, 'paused'); - queue.clean(0, 0, 'prioritized'); - queue.clean(0, 0, 'delayed'); - queue.clean(0, 0, 'failed'); + await Promise.all([ + queue.clean(0, 0, 'completed'), + queue.clean(0, 0, 'wait'), + queue.clean(0, 0, 'active'), + queue.clean(0, 0, 'paused'), + queue.clean(0, 0, 'prioritized'), + queue.clean(0, 0, 'delayed'), + queue.clean(0, 0, 'failed'), + ]); + } else { + await queue.clean(0, 0, state); + } + } + + @bindThis + public async queuePromoteJobs(queueType: typeof QUEUE_TYPES[number]) { + const queue = this.getQueue(queueType); + await queue.promoteJobs(); + } + + @bindThis + public async queueRetryJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + if (job.finishedOn != null) { + await job.retry(); + } else { + await job.promote(); + } + } + } + + @bindThis + public async queueRemoveJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + await job.remove(); + } + } + + @bindThis + private packJobData(job: Bull.Job) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : []; + stacktrace.reverse(); + + return { + id: job.id, + name: job.name, + data: job.data, + opts: job.opts, + timestamp: job.timestamp, + processedOn: job.processedOn, + processedBy: job.processedBy, + finishedOn: job.finishedOn, + progress: job.progress, + attempts: job.attemptsMade, + delay: job.delay, + failedReason: job.failedReason, + stacktrace: stacktrace, + returnValue: job.returnvalue, + isFailed: !!job.failedReason || (Array.isArray(stacktrace) && stacktrace.length > 0), + }; + } + + @bindThis + public async queueGetJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + return this.packJobData(job); } else { - queue.clean(0, 0, state); + throw new Error(`Job not found: ${jobId}`); } } + + @bindThis + public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) { + const RETURN_LIMIT = 100; + const queue = this.getQueue(queueType); + let jobs: Bull.Job[]; + + if (search) { + jobs = await queue.getJobs(jobTypes, 0, 1000); + + jobs = jobs.filter(job => { + const jobString = JSON.stringify(job).toLowerCase(); + return search.toLowerCase().split(' ').every(term => { + return jobString.includes(term); + }); + }); + + jobs = jobs.slice(0, RETURN_LIMIT); + } else { + jobs = await queue.getJobs(jobTypes, 0, RETURN_LIMIT); + } + + return jobs.map(job => this.packJobData(job)); + } + + @bindThis + public async queueGetQueues() { + const fetchings = QUEUE_TYPES.map(async type => { + const queue = this.getQueue(type); + + const counts = await queue.getJobCounts(); + const isPaused = await queue.isPaused(); + const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK); + const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK); + + return { + name: type, + counts: counts, + isPaused, + metrics: { + completed: metrics_completed, + failed: metrics_failed, + }, + }; + }); + + return await Promise.all(fetchings); + } + + @bindThis + public async queueGetQueue(queueType: typeof QUEUE_TYPES[number]) { + const queue = this.getQueue(queueType); + const counts = await queue.getJobCounts(); + const isPaused = await queue.isPaused(); + const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK); + const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK); + const db = parseRedisInfo(await (await queue.client).info()); + + return { + name: queueType, + qualifiedName: queue.qualifiedName, + counts: counts, + isPaused, + metrics: { + completed: metrics_completed, + failed: metrics_failed, + }, + db: { + version: db.redis_version, + mode: db.redis_mode, + runId: db.run_id, + processId: db.process_id, + port: parseInt(db.tcp_port), + os: db.os, + uptime: parseInt(db.uptime_in_seconds), + memory: { + total: parseInt(db.total_system_memory) || parseInt(db.maxmemory), + used: parseInt(db.used_memory), + fragmentationRatio: parseInt(db.mem_fragmentation_ratio), + peak: parseInt(db.used_memory_peak), + }, + clients: { + connected: parseInt(db.connected_clients), + blocked: parseInt(db.blocked_clients), + }, + }, + }; + } } diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 6940e1c188..c98ebcdcd9 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -44,7 +44,7 @@ import { BakeBufferedReactionsProcessorService } from './processors/BakeBuffered import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; -import { QUEUE, baseQueueOptions } from './const.js'; +import { QUEUE, baseWorkerOptions } from './const.js'; // ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 function httpRelatedBackoff(attemptsMade: number) { @@ -175,7 +175,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM), + ...baseWorkerOptions(this.config, QUEUE.SYSTEM), autorun: false, }); @@ -232,7 +232,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.DB), + ...baseWorkerOptions(this.config, QUEUE.DB), autorun: false, }); @@ -264,7 +264,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.deliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.DELIVER), + ...baseWorkerOptions(this.config, QUEUE.DELIVER), autorun: false, concurrency: this.config.deliverJobConcurrency ?? 128, limiter: { @@ -304,7 +304,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.inboxProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.INBOX), + ...baseWorkerOptions(this.config, QUEUE.INBOX), autorun: false, concurrency: this.config.inboxJobConcurrency ?? 16, limiter: { @@ -344,7 +344,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.userWebhookDeliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), + ...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), autorun: false, concurrency: 64, limiter: { @@ -384,7 +384,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.systemWebhookDeliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), + ...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), autorun: false, concurrency: 16, limiter: { @@ -434,7 +434,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.RELATIONSHIP), + ...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP), autorun: false, concurrency: this.config.relationshipJobConcurrency ?? 16, limiter: { @@ -479,7 +479,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE), + ...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE), autorun: false, concurrency: 16, }); @@ -512,7 +512,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.endedPollNotificationProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), + ...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), autorun: false, }); } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 67f689b618..7e146a7e03 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MetricsTime } from 'bullmq'; import { Config } from '@/config.js'; import type * as Bull from 'bullmq'; @@ -27,3 +28,12 @@ export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof t prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`, }; } + +export function baseWorkerOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.WorkerOptions { + return { + ...baseQueueOptions(config, queueName), + metrics: { + maxDataPoints: MetricsTime.ONE_WEEK, + }, + }; +} diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 34aaef3cc7..e5170aa2dc 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -67,8 +67,14 @@ export * as 'admin/promo/create' from './endpoints/admin/promo/create.js'; export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js'; export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-delayed.js'; export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-delayed.js'; -export * as 'admin/queue/promote' from './endpoints/admin/queue/promote.js'; +export * as 'admin/queue/retry-job' from './endpoints/admin/queue/retry-job.js'; +export * as 'admin/queue/remove-job' from './endpoints/admin/queue/remove-job.js'; +export * as 'admin/queue/show-job' from './endpoints/admin/queue/show-job.js'; +export * as 'admin/queue/promote-jobs' from './endpoints/admin/queue/promote-jobs.js'; +export * as 'admin/queue/jobs' from './endpoints/admin/queue/jobs.js'; export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js'; +export * as 'admin/queue/queues' from './endpoints/admin/queue/queues.js'; +export * as 'admin/queue/queue-stats' from './endpoints/admin/queue/queue-stats.js'; export * as 'admin/relays/add' from './endpoints/admin/relays/add.js'; export * as 'admin/relays/list' from './endpoints/admin/relays/list.js'; export * as 'admin/relays/remove' from './endpoints/admin/relays/remove.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts index 3978f14f2d..81cb4b8119 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -19,10 +19,10 @@ export const meta = { export const paramDef = { type: 'object', properties: { - type: { type: 'string', enum: QUEUE_TYPES }, - state: { type: 'string', enum: ['*', 'wait', 'delayed'] }, + queue: { type: 'string', enum: QUEUE_TYPES }, + state: { type: 'string', enum: ['*', 'completed', 'wait', 'active', 'paused', 'prioritized', 'delayed', 'failed'] }, }, - required: ['type', 'state'], + required: ['queue', 'state'], } as const; @Injectable() @@ -32,7 +32,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { - this.queueService.clearQueue(ps.type, ps.state); + this.queueService.queueClear(ps.queue, ps.state); this.moderationLogService.log(me, 'clearQueue'); }); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts new file mode 100644 index 0000000000..79731c9786 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed'] } }, + search: { type: 'string' }, + }, + required: ['queue', 'state'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetJobs(ps.queue, ps.state, ps.search); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts new file mode 100644 index 0000000000..d22385e261 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + }, + required: ['queue'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.queuePromoteJobs(ps.queue); + + this.moderationLogService.log(me, 'promoteQueue'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts deleted file mode 100644 index 7502d4e1f7..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { QueueService } from '@/core/QueueService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireModerator: true, - kind: 'write:admin:queue', -} as const; - -export const paramDef = { - type: 'object', - properties: { - type: { type: 'string', enum: ['deliver', 'inbox'] }, - }, - required: ['type'], -} as const; - -@Injectable() -export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export - constructor( - private moderationLogService: ModerationLogService, - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - let delayedQueues; - - switch (ps.type) { - case 'deliver': - delayedQueues = await this.queueService.deliverQueue.getDelayed(); - for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { - const queue = delayedQueues[queueIndex]; - try { - await queue.promote(); - } catch (e) { - if (e instanceof Error) { - if (e.message.indexOf('not in a delayed state') !== -1) { - throw e; - } - } else { - throw e; - } - } - } - break; - - case 'inbox': - delayedQueues = await this.queueService.inboxQueue.getDelayed(); - for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { - const queue = delayedQueues[queueIndex]; - try { - await queue.promote(); - } catch (e) { - if (e instanceof Error) { - if (e.message.indexOf('not in a delayed state') !== -1) { - throw e; - } - } else { - throw e; - } - } - } - break; - } - - this.moderationLogService.log(me, 'promoteQueue'); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts new file mode 100644 index 0000000000..10ce48332a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + }, + required: ['queue'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetQueue(ps.queue); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queues.ts b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts new file mode 100644 index 0000000000..3a38275f60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetQueues(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts new file mode 100644 index 0000000000..2c73f689d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.queueRemoveJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts new file mode 100644 index 0000000000..b2603128f8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.queueRetryJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts new file mode 100644 index 0000000000..63747b5540 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index d2d9f320ee..f41cb0d00b 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #fallback> <MkLoading/> </template> - <XCode v-if="show && lang" :code="code" :lang="lang"/> - <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> + <XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/> + <pre v-else-if="show" class="_selectable" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> <button v-else :class="$style.codePlaceholderRoot" @click="show = true"> <div :class="$style.codePlaceholderContainer"> <div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div> @@ -70,11 +70,9 @@ function copy() { .codeBlockFallbackRoot { display: block; overflow-wrap: anywhere; - background: var(--MI_THEME-bg); padding: 1em; - margin: .5em 0; + margin: 0; overflow: auto; - border-radius: 8px; } .codeBlockFallbackCode { diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 8b4bacba69..81689397cc 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -38,15 +38,26 @@ SPDX-License-Identifier: AGPL-3.0-only > <KeepAlive> <div v-show="opened"> - <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax"> - <slot></slot> - </MkSpacer> - <div v-else> - <slot></slot> - </div> - <div v-if="$slots.footer" :class="$style.footer"> - <slot name="footer"></slot> - </div> + <MkStickyContainer> + <template #header> + <div v-if="$slots.header" :class="$style.inBodyHeader"> + <slot name="header"></slot> + </div> + </template> + + <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax"> + <slot></slot> + </MkSpacer> + <div v-else> + <slot></slot> + </div> + + <template #footer> + <div v-if="$slots.footer" :class="$style.inBodyFooter"> + <slot name="footer"></slot> + </div> + </template> + </MkStickyContainer> </div> </KeepAlive> </Transition> @@ -230,14 +241,21 @@ onMounted(() => { &.bgSame { background: var(--MI_THEME-bg); + + .inBodyHeader { + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + } } } -.footer { - position: sticky !important; - z-index: 1; - bottom: var(--MI-stickyBottom, 0px); - left: 0; +.inBodyHeader { + background: color(from var(--MI_THEME-panel) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.inBodyFooter { padding: 12px; background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue new file mode 100644 index 0000000000..a1f30100d0 --- /dev/null +++ b/packages/frontend/src/components/MkTabs.vue @@ -0,0 +1,235 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.tabs"> + <div :class="$style.tabsInner"> + <button + v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" + class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]" + @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)" + > + <div :class="$style.tabInner"> + <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> + <div + v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)" + :class="$style.tabTitle" + > + {{ t.title }} + </div> + <Transition + v-else mode="in-out" @enter="enter" @afterEnter="afterEnter" @leave="leave" + @afterLeave="afterLeave" + > + <div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div> + </Transition> + </div> + </button> + </div> + <div + ref="tabHighlightEl" + :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]" + ></div> +</div> +</template> + +<script lang="ts"> +export type Tab = { + key: string; + onClick?: (ev: MouseEvent) => void; +} & ( + | { + iconOnly?: false; + title: string; + icon?: string; + } + | { + iconOnly: true; + icon: string; + } +); +</script> + +<script lang="ts" setup> +import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + tabs?: Tab[]; + tab?: string; +}>(), { + tabs: () => ([] as Tab[]), +}); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); + (ev: 'tabClick', key: string); +}>(); + +const tabHighlightEl = useTemplateRef('tabHighlightEl'); +const tabRefs: Record<string, HTMLElement | null> = {}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(t: Tab, ev: MouseEvent): void { + emit('tabClick', t.key); + + if (t.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + t.onClick(ev); + } + + if (t.key) { + emit('update:tab', t.key); + } +} + +function renderTab() { + const tabEl = props.tab ? tabRefs[props.tab] : undefined; + if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabHighlightEl.value.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.value.style.width = rect.width + 'px'; + tabHighlightEl.value.style.left = (rect.left - parentRect.left + tabHighlightEl.value.parentElement.scrollLeft) + 'px'; + } +} + +let entering = false; + +async function enter(el: Element) { + if (!(el instanceof HTMLElement)) return; + entering = true; + const elementWidth = el.getBoundingClientRect().width; + el.style.width = '0'; + el.style.paddingLeft = '0'; + el.offsetWidth; // reflow + el.style.width = `${elementWidth}px`; + el.style.paddingLeft = ''; + nextTick(() => { + entering = false; + }); + + window.setTimeout(renderTab, 170); +} + +function afterEnter(el: Element) { + if (!(el instanceof HTMLElement)) return; + // element.style.width = ''; +} + +async function leave(el: Element) { + if (!(el instanceof HTMLElement)) return; + const elementWidth = el.getBoundingClientRect().width; + el.style.width = `${elementWidth}px`; + el.style.paddingLeft = ''; + el.offsetWidth; // reflow + el.style.width = '0'; + el.style.paddingLeft = '0'; +} + +function afterLeave(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.width = ''; +} + +onMounted(() => { + watch([() => props.tab, () => props.tabs], () => { + nextTick(() => { + if (entering) return; + renderTab(); + }); + }, { + immediate: true, + }); +}); + +onUnmounted(() => { +}); +</script> + +<style lang="scss" module> +.tabs { + --height: 40px; + + display: block; + position: relative; + margin: 0; + height: var(--height); + font-size: 85%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.tabsInner { + display: inline-block; + height: var(--height); + white-space: nowrap; +} + +.tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + &.animate { + transition: opacity 0.2s ease; + } +} + +.tabInner { + display: flex; + align-items: center; +} + +.tabIcon + .tabTitle { + padding-left: 4px; +} + +.tabTitle { + overflow: hidden; + + &.animate { + transition: width .15s linear, padding-left .15s linear; + } +} + +.tabHighlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--MI_THEME-accent); + border-radius: 999px; + transition: none; + pointer-events: none; + + &.animate { + transition: width 0.15s ease, left 0.15s ease; + } +} +</style> diff --git a/packages/frontend/src/components/MkTl.vue b/packages/frontend/src/components/MkTl.vue new file mode 100644 index 0000000000..95cc4d2a2a --- /dev/null +++ b/packages/frontend/src/components/MkTl.vue @@ -0,0 +1,173 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.items"> + <template v-for="(item, i) in items" :key="item.id"> + <div :class="$style.left"> + <slot v-if="item.type === 'event'" name="left" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot> + </div> + <div :class="[$style.center, item.type === 'date' ? $style.date : '']"> + <div :class="$style.centerLine"></div> + <div :class="$style.centerPoint"></div> + </div> + <div :class="$style.right"> + <slot v-if="item.type === 'event'" name="right" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot> + <div v-else :class="$style.dateLabel"><i class="ti ti-chevron-up"></i> {{ item.prevText }}</div> + </div> + </template> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; + +const props = defineProps<{ + events: { + id: string; + timestamp: number; + data: any; + }[]; +}>(); + +const events = computed(() => { + return props.events.toSorted((a, b) => b.timestamp - a.timestamp); +}); + +function getDateText(dateInstance: Date) { + const year = dateInstance.getFullYear(); + const month = dateInstance.getMonth() + 1; + const date = dateInstance.getDate(); + const hour = dateInstance.getHours(); + return `${year.toString()}/${month.toString()}/${date.toString()} ${hour.toString().padStart(2, '0')}:00:00`; +} + +const items = computed<({ + id: string; + type: 'event'; + timestamp: number; + delta: number; + data: any; +} | { + id: string; + type: 'date'; + prev: Date; + prevText: string; + next: Date | null; + nextText: string; +})[]>(() => { + const results = []; + for (let i = 0; i < events.value.length; i++) { + const item = events.value[i]; + + const date = new Date(item.timestamp); + const nextDate = events.value[i + 1] ? new Date(events.value[i + 1].timestamp) : null; + + results.push({ + id: item.id, + type: 'event', + timestamp: item.timestamp, + delta: i === events.value.length - 1 ? 0 : item.timestamp - events.value[i + 1].timestamp, + data: item.data, + }); + + if ( + i !== events.value.length - 1 && + nextDate != null && ( + date.getFullYear() !== nextDate.getFullYear() || + date.getMonth() !== nextDate.getMonth() || + date.getDate() !== nextDate.getDate() || + date.getHours() !== nextDate.getHours() + ) + ) { + results.push({ + id: `date-${item.id}`, + type: 'date', + prev: date, + prevText: getDateText(date), + next: nextDate, + nextText: getDateText(nextDate), + }); + } + } + return results; + }); +</script> + +<style lang="scss" module> +.root { + +} + +.items { + display: grid; + grid-template-columns: max-content 18px 1fr; + gap: 0 8px; +} + +.item { +} + +.center { + position: relative; + + &.date { + .centerPoint::before { + position: absolute; + content: ""; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 7px; + height: 7px; + background: var(--MI_THEME-bg); + border-radius: 50%; + } + } +} + +.centerLine { + position: absolute; + top: 0; + left: 0; + right: 0; + margin: auto; + width: 3px; + height: 100%; + background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%); +} +.centerPoint { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 13px; + height: 13px; + background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%); + border-radius: 50%; +} + +.left { + min-width: 0; + align-self: center; + justify-self: right; +} + +.right { + min-width: 0; + align-self: center; +} + +.dateLabel { + opacity: 0.7; + font-size: 90%; + padding: 4px; + margin: 8px 0; +} +</style> diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue index 5dd2887024..5dd2887024 100644 --- a/packages/frontend/src/pages/admin/queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/federation-job-queue.chart.vue index 1ba02d6e0e..4b10d682a5 100644 --- a/packages/frontend/src/pages/admin/queue.chart.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.chart.vue @@ -50,8 +50,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import XChart from './queue.chart.chart.vue'; -import type { ApQueueDomain } from '@/pages/admin/queue.vue'; +import XChart from './federation-job-queue.chart.chart.vue'; +import type { ApQueueDomain } from '@/pages/admin/federation-job-queue.vue'; import number from '@/filters/number.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/federation-job-queue.vue index ee56c258c9..df976ba999 100644 --- a/packages/frontend/src/pages/admin/queue.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; -import XQueue from './queue.chart.vue'; +import XQueue from './federation-job-queue.chart.vue'; import XHeader from './_header_.vue'; import type { Ref } from 'vue'; import * as os from '@/os.js'; @@ -40,7 +40,7 @@ function clear() { }).then(({ canceled }) => { if (canceled) return; - os.apiWithDialog('admin/queue/clear', { type: tab.value, state: '*' }); + os.apiWithDialog('admin/queue/clear', { queue: tab.value, state: '*' }); }); } @@ -52,7 +52,7 @@ function promoteAllQueues() { }).then(({ canceled }) => { if (canceled) return; - os.apiWithDialog('admin/queue/promote', { type: tab.value }); + os.apiWithDialog('admin/queue/promote-jobs', { queue: tab.value }); }); } @@ -67,7 +67,7 @@ const headerTabs = computed(() => [{ }]); definePage(() => ({ - title: i18n.ts.jobQueue, + title: i18n.ts.federationJobs, icon: 'ti ti-clock-play', })); </script> diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 8d03838a8f..d2246b7512 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkSpacer> </div> - <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main _pageContainer" style="height: 100%;"> <NestedRouterView/> </div> </div> @@ -140,9 +140,14 @@ const menuDef = computed<SuperMenuDef[]>(() => [{ active: currentPage.value?.route.name === 'federation', }, { icon: 'ti ti-clock-play', + text: i18n.ts.federationJobs, + to: '/admin/federation-job-queue', + active: currentPage.value?.route.name === 'federationJobQueue', + }, { + icon: 'ti ti-clock-play', text: i18n.ts.jobQueue, - to: '/admin/queue', - active: currentPage.value?.route.name === 'queue', + to: '/admin/job-queue', + active: currentPage.value?.route.name === 'jobQueue', }, { icon: 'ti ti-cloud', text: i18n.ts.files, @@ -329,6 +334,8 @@ defineExpose({ <style lang="scss" scoped> .hiyeyicy { + height: 100%; + &.wide { display: flex; margin: 0 auto; diff --git a/packages/frontend/src/pages/admin/job-queue.chart.vue b/packages/frontend/src/pages/admin/job-queue.chart.vue new file mode 100644 index 0000000000..f42b35105e --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.chart.vue @@ -0,0 +1,127 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { onMounted, useTemplateRef, watch } from 'vue'; +import { Chart } from 'chart.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; + +initChart(); + +const props = defineProps<{ + dataSet: { + completed: number[]; + failed: number[]; + }; + aspectRatio?: number; +}>(); + +const chartEl = useTemplateRef('chartEl'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData() { + if (chartInstance == null) return; + chartInstance.data.labels = []; + for (let i = 0; i < Math.max(props.dataSet.completed.length, props.dataSet.failed.length); i++) { + chartInstance.data.labels.push(''); + } + chartInstance.data.datasets[0].data = props.dataSet.completed; + chartInstance.data.datasets[1].data = props.dataSet.failed; + chartInstance.update(); +} + +watch(() => props.dataSet, () => { + setData(); +}); + +onMounted(() => { + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Completed', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#4caf50', + backgroundColor: alpha('#4caf50', 0.2), + fill: true, + data: [], + }, { + label: 'Failed', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#ff0000', + backgroundColor: alpha('#ff0000', 0.2), + fill: true, + data: [], + }], + }, + options: { + aspectRatio: props.aspectRatio ?? 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: true, + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + setData(); +}); +</script> diff --git a/packages/frontend/src/pages/admin/job-queue.job.vue b/packages/frontend/src/pages/admin/job-queue.job.vue new file mode 100644 index 0000000000..71efab0272 --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.job.vue @@ -0,0 +1,280 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder> + <template #label> + <span v-if="job.opts.repeat != null" style="margin-right: 1em;"><repeat></span> + <span v-else style="margin-right: 1em;">#{{ job.id }}</span> + <span>{{ job.name }}</span> + </template> + <template #suffix> + <MkTime :time="job.finishedOn ?? job.processedOn ?? job.timestamp" mode="relative"/> + <span v-if="job.progress != null && job.progress > 0" style="margin-left: 1em;">{{ Math.floor(job.progress * 100) }}%</span> + <span v-if="job.opts.attempts != null && job.opts.attempts > 0 && job.attempts > 1" style="margin-left: 1em; color: var(--MI_THEME-warn); font-variant-numeric: diagonal-fractions;">{{ job.attempts }}/{{ job.opts.attempts }}</span> + <span v-if="job.isFailed && job.finishedOn != null" style="margin-left: 1em; color: var(--MI_THEME-error)"><i class="ti ti-circle-x"></i></span> + <span v-else-if="job.isFailed" style="margin-left: 1em; color: var(--MI_THEME-warn)"><i class="ti ti-alert-triangle"></i></span> + <span v-else-if="job.finishedOn != null" style="margin-left: 1em; color: var(--MI_THEME-success)"><i class="ti ti-check"></i></span> + <span v-else-if="job.delay != null && job.delay != 0" style="margin-left: 1em;"><i class="ti ti-clock"></i></span> + <span v-else-if="job.processedOn != null" style="margin-left: 1em; color: var(--MI_THEME-success)"><i class="ti ti-player-play"></i></span> + </template> + <template #header> + <MkTabs + v-model:tab="tab" + :tabs="[{ + key: 'info', + title: 'Info', + icon: 'ti ti-info-circle', + }, { + key: 'timeline', + title: 'Timeline', + icon: 'ti ti-timeline-event', + }, { + key: 'data', + title: 'Data', + icon: 'ti ti-package', + }, ...(canEdit ? [{ + key: 'dataEdit', + title: 'Data (edit)', + icon: 'ti ti-package', + }] : []), + ...(job.returnValue != null ? [{ + key: 'result', + title: 'Result', + icon: 'ti ti-check', + }] : []), + ...(job.stacktrace.length > 0 ? [{ + key: 'error', + title: 'Error', + icon: 'ti ti-alert-triangle', + }] : []), { + key: 'logs', + title: 'Logs', + icon: 'ti ti-logs', + }]" + /> + </template> + <template #footer> + <div class="_buttons"> + <MkButton rounded @click="copyRaw()"><i class="ti ti-copy"></i> Copy raw</MkButton> + <MkButton rounded @click="refresh()"><i class="ti ti-reload"></i> Refresh view</MkButton> + <MkButton rounded @click="promoteJob()"><i class="ti ti-player-track-next"></i> Promote</MkButton> + <MkButton rounded @click="moveJob"><i class="ti ti-arrow-right"></i> Move to</MkButton> + <MkButton danger rounded style="margin-left: auto;" @click="removeJob()"><i class="ti ti-trash"></i> Remove</MkButton> + </div> + </template> + + <div v-if="tab === 'info'" class="_gaps_s"> + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px;"> + <MkKeyValue> + <template #key>ID</template> + <template #value>{{ job.id }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Created at</template> + <template #value><MkTime :time="job.timestamp" mode="detail"/></template> + </MkKeyValue> + <MkKeyValue v-if="job.processedOn != null"> + <template #key>Processed at</template> + <template #value><MkTime :time="job.processedOn" mode="detail"/></template> + </MkKeyValue> + <MkKeyValue v-if="job.finishedOn != null"> + <template #key>Finished at</template> + <template #value><MkTime :time="job.finishedOn" mode="detail"/></template> + </MkKeyValue> + <MkKeyValue v-if="job.processedOn != null && job.finishedOn != null"> + <template #key>Spent</template> + <template #value>{{ job.finishedOn - job.processedOn }}ms</template> + </MkKeyValue> + <MkKeyValue v-if="job.failedReason != null"> + <template #key>Failed reason</template> + <template #value><i style="color: var(--MI_THEME-error)" class="ti ti-alert-triangle"></i> {{ job.failedReason }}</template> + </MkKeyValue> + <MkKeyValue v-if="job.opts.attempts != null && job.opts.attempts > 0"> + <template #key>Attempts</template> + <template #value>{{ job.attempts }} of {{ job.opts.attempts }}</template> + </MkKeyValue> + <MkKeyValue v-if="job.progress != null && job.progress > 0"> + <template #key>Progress</template> + <template #value>{{ Math.floor(job.progress * 100) }}%</template> + </MkKeyValue> + </div> + <MkFolder :withSpacer="false"> + <template #label>Options</template> + <MkCode :code="JSON5.stringify(job.opts, null, '\t')" lang="js"/> + </MkFolder> + </div> + <div v-else-if="tab === 'timeline'"> + <MkTl :events="timeline"> + <template #left="{ event }"> + <div> + <template v-if="event.type === 'finished'"> + <template v-if="job.isFailed"> + <b>Finished</b> <i class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i> + </template> + <template v-else> + <b>Finished</b> <i class="ti ti-check" style="color: var(--MI_THEME-success);"></i> + </template> + </template> + <template v-else-if="event.type === 'processed'"> + <b>Processed</b> <i class="ti ti-player-play"></i> + </template> + <template v-else-if="event.type === 'attempt'"> + <b>Attempt #{{ event.attempt }}</b> <i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> + </template> + <template v-else-if="event.type === 'created'"> + <b>Created</b> <i class="ti ti-plus"></i> + </template> + </div> + </template> + <template #right="{ event, timestamp, delta }"> + <div style="margin: 8px 0;"> + <template v-if="event.type === 'attempt'"> + <div>at ?</div> + </template> + <template v-else> + <div>at <MkTime :time="timestamp" mode="detail"/></div> + <div style="font-size: 90%; opacity: 0.7;">{{ timestamp }} (+{{ msSMH(delta) }})</div> + </template> + </div> + </template> + </MkTl> + </div> + <div v-else-if="tab === 'data'"> + <MkCode :code="JSON5.stringify(job.data, null, '\t')" lang="js"/> + </div> + <div v-else-if="tab === 'dataEdit'" class="_gaps_s"> + <MkCodeEditor v-model="editData" lang="json5"></MkCodeEditor> + <MkButton><i class="ti ti-device-floppy"></i> Update</MkButton> + </div> + <div v-else-if="tab === 'result'"> + <MkCode :code="job.returnValue"/> + </div> + <div v-else-if="tab === 'error'" class="_gaps_s"> + <MkCode v-for="log in job.stacktrace" :code="log" lang="stacktrace"/> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { ref, computed, watch } from 'vue'; +import JSON5 from 'json5'; +import type { Ref } from 'vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkTabs from '@/components/MkTabs.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkCodeEditor from '@/components/MkCodeEditor.vue'; +import MkTl from '@/components/MkTl.vue'; +import kmg from '@/filters/kmg.js'; +import bytes from '@/filters/bytes.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; + +function msSMH(v: number | null) { + if (v == null) return 'N/A'; + if (v === 0) return '0'; + const suffixes = ['ms', 's', 'm', 'h']; + const isMinus = v < 0; + if (isMinus) v = -v; + const i = Math.floor(Math.log(v) / Math.log(1000)); + const value = v / Math.pow(1000, i); + const suffix = suffixes[i]; + return `${isMinus ? '-' : ''}${value.toFixed(1)}${suffix}`; +} + +const props = defineProps<{ + job: any; + queueType: string; +}>(); + +const emit = defineEmits<{ + (ev: 'needRefresh'): void, +}>(); + +const tab = ref('info'); +const editData = ref(JSON5.stringify(props.job.data, null, '\t')); +const canEdit = true; +const timeline = computed(() => { + const events = [{ + id: 'created', + timestamp: props.job.timestamp, + data: { + type: 'created', + }, + }]; + if (props.job.attempts > 1) { + for (let i = 1; i < props.job.attempts; i++) { + events.push({ + id: `attempt-${i}`, + timestamp: props.job.timestamp + i, + data: { + type: 'attempt', + attempt: i, + }, + }); + } + } + if (props.job.processedOn != null) { + events.push({ + id: 'processed', + timestamp: props.job.processedOn, + data: { + type: 'processed', + }, + }); + } + if (props.job.finishedOn != null) { + events.push({ + id: 'finished', + timestamp: props.job.finishedOn, + data: { + type: 'finished', + }, + }); + } + return events; +}); + +async function promoteJob() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/retry-job', { queue: props.queueType, jobId: props.job.id }); +} + +async function removeJob() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/remove-job', { queue: props.queueType, jobId: props.job.id }); +} + +function moveJob() { + // TODO +} + +function refresh() { + emit('needRefresh'); +} + +function copyRaw() { + const raw = JSON.stringify(props.job, null, '\t'); + copyToClipboard(raw); +} +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/admin/job-queue.vue b/packages/frontend/src/pages/admin/job-queue.vue new file mode 100644 index 0000000000..528c473c4f --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.vue @@ -0,0 +1,370 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkSpacer> + <div v-if="tab === '-'" class="_gaps"> + <div :class="$style.queues"> + <div v-for="q in queueInfos" :key="q.name" :class="$style.queue" @click="tab = q.name"> + <div style="display: flex; align-items: center; font-weight: bold;"><i class="ti ti-http-que" style="margin-right: 0.5em;"></i>{{ q.name }}<i v-if="!q.isPaused" style="color: var(--MI_THEME-success); margin-left: auto;" class="ti ti-player-play"></i></div> + <div :class="$style.queueCounts"> + <MkKeyValue> + <template #key>Active</template> + <template #value>{{ kmg(q.counts.active, 2) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Delayed</template> + <template #value>{{ kmg(q.counts.delayed, 2) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Waiting</template> + <template #value>{{ kmg(q.counts.waiting, 2) }}</template> + </MkKeyValue> + </div> + <XChart :dataSet="{ completed: q.metrics.completed.data, failed: q.metrics.failed.data }"/> + </div> + </div> + </div> + <div v-else-if="queueInfo" class="_gaps"> + <MkFolder :defaultOpen="true"> + <template #label>Overview: {{ tab }}</template> + <template #icon><i class="ti ti-http-que"></i></template> + <template #suffix>#{{ queueInfo.db.processId }}:{{ queueInfo.db.port }} / {{ queueInfo.db.runId }}</template> + <template #caption>{{ queueInfo.qualifiedName }}</template> + <template #footer> + <div class="_buttons"> + <MkButton rounded @click="promoteAllJobs"><i class="ti ti-player-track-next"></i> Promote all jobs</MkButton> + <MkButton rounded @click="createJob"><i class="ti ti-plus"></i> Add job</MkButton> + <MkButton v-if="queueInfo.isPaused" rounded @click="resumeQueue"><i class="ti ti-player-play"></i> Resume queue</MkButton> + <MkButton v-else rounded danger @click="pauseQueue"><i class="ti ti-player-pause"></i> Pause queue</MkButton> + <MkButton rounded danger @click="clearQueue"><i class="ti ti-trash"></i> Empty queue</MkButton> + </div> + </template> + + <div class="_gaps"> + <XChart :dataSet="{ completed: queueInfo.metrics.completed.data, failed: queueInfo.metrics.failed.data }" :aspectRatio="5"/> + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px;"> + <MkKeyValue> + <template #key>Active</template> + <template #value>{{ kmg(queueInfo.counts.active, 2) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Delayed</template> + <template #value>{{ kmg(queueInfo.counts.delayed, 2) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Waiting</template> + <template #value>{{ kmg(queueInfo.counts.waiting, 2) }}</template> + </MkKeyValue> + </div> + <hr> + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px;"> + <MkKeyValue> + <template #key>Clients: Connected</template> + <template #value>{{ queueInfo.db.clients.connected }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Clients: Blocked</template> + <template #value>{{ queueInfo.db.clients.blocked }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Memory: Peak</template> + <template #value>{{ bytes(queueInfo.db.memory.peak, 1) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Memory: Total</template> + <template #value>{{ bytes(queueInfo.db.memory.total, 1) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Memory: Used</template> + <template #value>{{ bytes(queueInfo.db.memory.used, 1) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Uptime</template> + <template #value>{{ queueInfo.db.uptime }}</template> + </MkKeyValue> + </div> + </div> + </MkFolder> + + <MkFolder :defaultOpen="true" :withSpacer="false"> + <template #label>Jobs: {{ tab }}</template> + <template #icon><i class="ti ti-list-check"></i></template> + <template #suffix><A:{{ kmg(queueInfo.counts.active, 2) }}> <D:{{ kmg(queueInfo.counts.delayed, 2) }}> <W:{{ kmg(queueInfo.counts.waiting, 2) }}></template> + <template #header> + <MkTabs + v-model:tab="jobState" + :class="$style.jobsTabs" :tabs="[{ + key: 'all', + title: 'All', + icon: 'ti ti-code-asterisk', + }, { + key: 'latest', + title: 'Latest', + icon: 'ti ti-logs', + }, { + key: 'completed', + title: 'Completed', + icon: 'ti ti-check', + }, { + key: 'failed', + title: 'Failed', + icon: 'ti ti-circle-x', + }, { + key: 'active', + title: 'Active', + icon: 'ti ti-player-play', + }, { + key: 'delayed', + title: 'Delayed', + icon: 'ti ti-clock', + }, { + key: 'wait', + title: 'Waiting', + icon: 'ti ti-hourglass-high', + }, { + key: 'paused', + title: 'Paused', + icon: 'ti ti-player-pause', + }]" + /> + </template> + <template #footer> + <div class="_buttons"> + <MkButton rounded @click="fetchJobs()"><i class="ti ti-reload"></i> Refresh view</MkButton> + <MkButton rounded danger style="margin-left: auto;" @click="removeJobs"><i class="ti ti-trash"></i> Remove jobs</MkButton> + </div> + </template> + + <MkSpacer> + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts.search" + type="search" + style="margin-bottom: 16px;" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkLoading v-if="jobsFetching"/> + <MkTl + v-else + :events="jobs.map((job) => ({ + id: job.id, + timestamp: job.finishedOn ?? job.processedOn ?? job.timestamp, + data: job, + }))" + class="_monospace" + > + <template #right="{ event: job }"> + <XJob :job="job" :queueType="tab" style="margin: 4px 0;" @needRefresh="refreshJob(job.id)"/> + </template> + </MkTl> + </MkSpacer> + </MkFolder> + </div> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref, computed, watch } from 'vue'; +import JSON5 from 'json5'; +import { debounce } from 'throttle-debounce'; +import { useInterval } from '@@/js/use-interval.js'; +import XChart from './job-queue.chart.vue'; +import XJob from './job-queue.job.vue'; +import type { Ref } from 'vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import MkButton from '@/components/MkButton.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkTabs from '@/components/MkTabs.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkTl from '@/components/MkTl.vue'; +import kmg from '@/filters/kmg.js'; +import MkInput from '@/components/MkInput.vue'; +import bytes from '@/filters/bytes.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; + +const QUEUE_TYPES = [ + 'system', + 'endedPollNotification', + 'deliver', + 'inbox', + 'db', + 'relationship', + 'objectStorage', + 'userWebhookDeliver', + 'systemWebhookDeliver', +] as const; + +const tab: Ref<typeof QUEUE_TYPES[number] | '-'> = ref('-'); +const jobState = ref('all'); +const jobs = ref([]); +const jobsFetching = ref(true); +const queueInfos = ref([]); +const queueInfo = ref(); +const searchQuery = ref(''); + +async function fetchQueues() { + if (tab.value !== '-') return; + queueInfos.value = await misskeyApi('admin/queue/queues'); +} + +async function fetchCurrentQueue() { + if (tab.value === '-') return; + queueInfo.value = await misskeyApi('admin/queue/queue-stats', { queue: tab.value }); +} + +async function fetchJobs() { + jobsFetching.value = true; + const state = jobState.value; + jobs.value = await misskeyApi('admin/queue/jobs', { + queue: tab.value, + state: state === 'all' ? ['completed', 'failed', 'active', 'delayed', 'wait'] : state === 'latest' ? ['completed', 'failed'] : [state], + search: searchQuery.value.trim() === '' ? undefined : searchQuery.value, + }).then(res => { + if (state === 'all') { + res.sort((a, b) => (a.processedOn ?? a.timestamp) > (b.processedOn ?? b.timestamp) ? -1 : 1); + } else if (state === 'latest') { + res.sort((a, b) => a.processedOn > b.processedOn ? -1 : 1); + } else if (state === 'delayed') { + res.sort((a, b) => (a.processedOn ?? a.timestamp) > (b.processedOn ?? b.timestamp) ? -1 : 1); + } + return res; + }); + jobsFetching.value = false; +} + +watch([tab], async () => { + if (tab.value === '-') { + fetchQueues(); + } else { + fetchCurrentQueue(); + fetchJobs(); + } +}, { immediate: true }); + +watch([jobState], () => { + fetchJobs(); +}); + +const search = debounce(1000, () => { + fetchJobs(); +}); + +watch([searchQuery], () => { + search(); +}); + +useInterval(() => { + if (tab.value === '-') { + fetchQueues(); + } else { + fetchCurrentQueue(); + } +}, 1000 * 10, { + immediate: false, + afterMounted: true, +}); + +async function clearQueue() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/clear', { queue: tab.value, state: '*' }); + + fetchCurrentQueue(); + fetchJobs(); +} + +async function promoteAllJobs() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/promote-jobs', { queue: tab.value }); + + fetchCurrentQueue(); + fetchJobs(); +} + +async function removeJobs() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/clear', { queue: tab.value, state: jobState.value }); + + fetchCurrentQueue(); + fetchJobs(); +} + +async function refreshJob(jobId: string) { + const newJob = await misskeyApi('admin/queue/show-job', { queue: tab.value, jobId }); + const index = jobs.value.findIndex((job) => job.id === jobId); + if (index !== -1) { + jobs.value[index] = newJob; + } +} + +const headerActions = computed(() => []); + +const headerTabs = computed(() => + [{ + key: '-', + title: i18n.ts.overview, + icon: 'ti ti-dashboard', + }].concat(QUEUE_TYPES.map((t) => ({ + key: t, + title: t, + }))), +); + +definePage(() => ({ + title: i18n.ts.jobQueue, + icon: 'ti ti-clock-play', + needWideArea: true, +})); +</script> + +<style lang="scss" module> +.queues { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; +} + +.queue { + padding: 14px 18px; + background-color: var(--MI_THEME-panel); + border-radius: 8px; + cursor: pointer; +} + +.queueCounts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; + font-size: 85%; + margin: 6px 0; +} + +.jobsTabs { + +} +</style> diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index d59b160b8b..a0a22b4338 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -392,9 +392,13 @@ export const ROUTE_DEF = [{ name: 'avatarDecorations', component: page(() => import('@/pages/avatar-decorations.vue')), }, { - path: '/queue', - name: 'queue', - component: page(() => import('@/pages/admin/queue.vue')), + path: '/federation-job-queue', + name: 'federationJobQueue', + component: page(() => import('@/pages/admin/federation-job-queue.vue')), + }, { + path: '/job-queue', + name: 'jobQueue', + component: page(() => import('@/pages/admin/job-queue.vue')), }, { path: '/files', name: 'files', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 5237d5bbf8..b43906109f 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -267,7 +267,22 @@ type AdminQueueDeliverDelayedResponse = operations['admin___queue___deliver-dela type AdminQueueInboxDelayedResponse = operations['admin___queue___inbox-delayed']['responses']['200']['content']['application/json']; // @public (undocumented) -type AdminQueuePromoteRequest = operations['admin___queue___promote']['requestBody']['content']['application/json']; +type AdminQueueJobsRequest = operations['admin___queue___jobs']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminQueuePromoteJobsRequest = operations['admin___queue___promote-jobs']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminQueueQueueStatsRequest = operations['admin___queue___queue-stats']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminQueueRemoveJobRequest = operations['admin___queue___remove-job']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminQueueRetryJobRequest = operations['admin___queue___retry-job']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminQueueShowJobRequest = operations['admin___queue___show-job']['requestBody']['content']['application/json']; // @public (undocumented) type AdminQueueStatsResponse = operations['admin___queue___stats']['responses']['200']['content']['application/json']; @@ -1531,7 +1546,12 @@ declare namespace entities { AdminQueueClearRequest, AdminQueueDeliverDelayedResponse, AdminQueueInboxDelayedResponse, - AdminQueuePromoteRequest, + AdminQueueJobsRequest, + AdminQueuePromoteJobsRequest, + AdminQueueQueueStatsRequest, + AdminQueueRemoveJobRequest, + AdminQueueRetryJobRequest, + AdminQueueShowJobRequest, AdminQueueStatsResponse, AdminRelaysAddRequest, AdminRelaysAddResponse, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index ae97084116..b607c93e1e 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -639,9 +639,75 @@ declare module '../api.js' { /** * No description provided. * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + request<E extends 'admin/queue/jobs', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:queue* + */ + request<E extends 'admin/queue/promote-jobs', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + request<E extends 'admin/queue/queue-stats', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + request<E extends 'admin/queue/queues', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:queue* + */ + request<E extends 'admin/queue/remove-job', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** + * No description provided. + * * **Credential required**: *Yes* / **Permission**: *write:admin:queue* */ - request<E extends 'admin/queue/promote', P extends Endpoints[E]['req']>( + request<E extends 'admin/queue/retry-job', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + request<E extends 'admin/queue/show-job', P extends Endpoints[E]['req']>( endpoint: E, params: P, credential?: string | null, diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index d22395e866..6390314429 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -78,7 +78,12 @@ import type { AdminQueueClearRequest, AdminQueueDeliverDelayedResponse, AdminQueueInboxDelayedResponse, - AdminQueuePromoteRequest, + AdminQueueJobsRequest, + AdminQueuePromoteJobsRequest, + AdminQueueQueueStatsRequest, + AdminQueueRemoveJobRequest, + AdminQueueRetryJobRequest, + AdminQueueShowJobRequest, AdminQueueStatsResponse, AdminRelaysAddRequest, AdminRelaysAddResponse, @@ -694,7 +699,13 @@ export type Endpoints = { 'admin/queue/clear': { req: AdminQueueClearRequest; res: EmptyResponse }; 'admin/queue/deliver-delayed': { req: EmptyRequest; res: AdminQueueDeliverDelayedResponse }; 'admin/queue/inbox-delayed': { req: EmptyRequest; res: AdminQueueInboxDelayedResponse }; - 'admin/queue/promote': { req: AdminQueuePromoteRequest; res: EmptyResponse }; + 'admin/queue/jobs': { req: AdminQueueJobsRequest; res: EmptyResponse }; + 'admin/queue/promote-jobs': { req: AdminQueuePromoteJobsRequest; res: EmptyResponse }; + 'admin/queue/queue-stats': { req: AdminQueueQueueStatsRequest; res: EmptyResponse }; + 'admin/queue/queues': { req: EmptyRequest; res: EmptyResponse }; + 'admin/queue/remove-job': { req: AdminQueueRemoveJobRequest; res: EmptyResponse }; + 'admin/queue/retry-job': { req: AdminQueueRetryJobRequest; res: EmptyResponse }; + 'admin/queue/show-job': { req: AdminQueueShowJobRequest; res: EmptyResponse }; 'admin/queue/stats': { req: EmptyRequest; res: AdminQueueStatsResponse }; 'admin/relays/add': { req: AdminRelaysAddRequest; res: AdminRelaysAddResponse }; 'admin/relays/list': { req: EmptyRequest; res: AdminRelaysListResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index a772c1559d..f814d7b3da 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -81,7 +81,12 @@ export type AdminPromoCreateRequest = operations['admin___promo___create']['requ export type AdminQueueClearRequest = operations['admin___queue___clear']['requestBody']['content']['application/json']; export type AdminQueueDeliverDelayedResponse = operations['admin___queue___deliver-delayed']['responses']['200']['content']['application/json']; export type AdminQueueInboxDelayedResponse = operations['admin___queue___inbox-delayed']['responses']['200']['content']['application/json']; -export type AdminQueuePromoteRequest = operations['admin___queue___promote']['requestBody']['content']['application/json']; +export type AdminQueueJobsRequest = operations['admin___queue___jobs']['requestBody']['content']['application/json']; +export type AdminQueuePromoteJobsRequest = operations['admin___queue___promote-jobs']['requestBody']['content']['application/json']; +export type AdminQueueQueueStatsRequest = operations['admin___queue___queue-stats']['requestBody']['content']['application/json']; +export type AdminQueueRemoveJobRequest = operations['admin___queue___remove-job']['requestBody']['content']['application/json']; +export type AdminQueueRetryJobRequest = operations['admin___queue___retry-job']['requestBody']['content']['application/json']; +export type AdminQueueShowJobRequest = operations['admin___queue___show-job']['requestBody']['content']['application/json']; export type AdminQueueStatsResponse = operations['admin___queue___stats']['responses']['200']['content']['application/json']; export type AdminRelaysAddRequest = operations['admin___relays___add']['requestBody']['content']['application/json']; export type AdminRelaysAddResponse = operations['admin___relays___add']['responses']['200']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 3c51bedb5c..bb7ba30f1b 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -531,14 +531,68 @@ export type paths = { */ post: operations['admin___queue___inbox-delayed']; }; - '/admin/queue/promote': { + '/admin/queue/jobs': { /** - * admin/queue/promote + * admin/queue/jobs + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + post: operations['admin___queue___jobs']; + }; + '/admin/queue/promote-jobs': { + /** + * admin/queue/promote-jobs * @description No description provided. * * **Credential required**: *Yes* / **Permission**: *write:admin:queue* */ - post: operations['admin___queue___promote']; + post: operations['admin___queue___promote-jobs']; + }; + '/admin/queue/queue-stats': { + /** + * admin/queue/queue-stats + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + post: operations['admin___queue___queue-stats']; + }; + '/admin/queue/queues': { + /** + * admin/queue/queues + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + post: operations['admin___queue___queues']; + }; + '/admin/queue/remove-job': { + /** + * admin/queue/remove-job + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:queue* + */ + post: operations['admin___queue___remove-job']; + }; + '/admin/queue/retry-job': { + /** + * admin/queue/retry-job + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:queue* + */ + post: operations['admin___queue___retry-job']; + }; + '/admin/queue/show-job': { + /** + * admin/queue/show-job + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + post: operations['admin___queue___show-job']; }; '/admin/queue/stats': { /** @@ -8809,9 +8863,9 @@ export type operations = { content: { 'application/json': { /** @enum {string} */ - type: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; /** @enum {string} */ - state: '*' | 'wait' | 'delayed'; + state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed'; }; }; }; @@ -8945,17 +8999,326 @@ export type operations = { }; }; /** - * admin/queue/promote + * admin/queue/jobs + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + admin___queue___jobs: { + requestBody: { + content: { + 'application/json': { + /** @enum {string} */ + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed')[]; + search?: string; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/queue/promote-jobs * @description No description provided. * * **Credential required**: *Yes* / **Permission**: *write:admin:queue* */ - admin___queue___promote: { + 'admin___queue___promote-jobs': { + requestBody: { + content: { + 'application/json': { + /** @enum {string} */ + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/queue/queue-stats + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + 'admin___queue___queue-stats': { + requestBody: { + content: { + 'application/json': { + /** @enum {string} */ + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/queue/queues + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + admin___queue___queues: { + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/queue/remove-job + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:queue* + */ + 'admin___queue___remove-job': { + requestBody: { + content: { + 'application/json': { + /** @enum {string} */ + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + jobId: string; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/queue/retry-job + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:queue* + */ + 'admin___queue___retry-job': { + requestBody: { + content: { + 'application/json': { + /** @enum {string} */ + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + jobId: string; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * admin/queue/show-job + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:queue* + */ + 'admin___queue___show-job': { requestBody: { content: { 'application/json': { /** @enum {string} */ - type: 'deliver' | 'inbox'; + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + jobId: string; }; }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d142cde7b7..858060b731 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: '@twemoji/parser': specifier: 15.1.1 version: 15.1.1 + '@types/redis-info': + specifier: 3.0.3 + version: 3.0.3 accepts: specifier: 1.3.8 version: 1.3.8 @@ -368,6 +371,9 @@ importers: re2: specifier: 1.21.4 version: 1.21.4(patch_hash=018babd22b7ce951bcd10d6246f1e541a7ac7ba212f7fa8985e774ece67d08e1) + redis-info: + specifier: 3.1.0 + version: 3.1.0 redis-lock: specifier: 0.1.4 version: 0.1.4 @@ -4462,6 +4468,9 @@ packages: '@types/readdir-glob@1.1.1': resolution: {integrity: sha512-ImM6TmoF8bgOwvehGviEj3tRdRBbQujr1N+0ypaln/GWjaerOB26jb93vsRHmdMtvVQZQebOlqt2HROark87mQ==} + '@types/redis-info@3.0.3': + resolution: {integrity: sha512-VIkNy6JbYI/RLdbPHdm9JQvv6RVld2uE2/6Hdid38Qdq+zvDli2FTpImI8pC5zwp8xS8qVqfzlfyAub8xZEd5g==} + '@types/rename@1.0.7': resolution: {integrity: sha512-E9qapfghUGfBMi3jNhsmCKPIp3f2zvNKpaX1BDGLGJNjzpgsZ/RTx7NaNksFjGoJ+r9NvWF1NSM5vVecnNjVmw==} @@ -9429,6 +9438,9 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} + redis-info@3.1.0: + resolution: {integrity: sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg==} + redis-lock@0.1.4: resolution: {integrity: sha512-7/+zu86XVQfJVx1nHTzux5reglDiyUCDwmW7TSlvVezfhH2YLc/Rc8NE0ejQG+8/0lwKzm29/u/4+ogKeLosiA==} engines: {node: '>=0.6'} @@ -14937,6 +14949,8 @@ snapshots: dependencies: '@types/node': 22.14.0 + '@types/redis-info@3.0.3': {} + '@types/rename@1.0.7': {} '@types/resolve@1.20.3': {} @@ -21162,6 +21176,10 @@ snapshots: redis-errors@1.2.0: {} + redis-info@3.1.0: + dependencies: + lodash: 4.17.21 + redis-lock@0.1.4: {} redis-parser@3.0.0: |