diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-04-19 14:00:38 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-19 14:00:38 +0900 |
| commit | 7b38806413b84bd20070f3c81b899e5b890b1a8b (patch) | |
| tree | 2385b32546d410ff2e5e208793f7ad82ad6623ab /packages/frontend/src | |
| parent | fix(storybook): implement missing stories (#15862) (diff) | |
| download | misskey-7b38806413b84bd20070f3c81b899e5b890b1a8b.tar.gz misskey-7b38806413b84bd20070f3c81b899e5b890b1a8b.tar.bz2 misskey-7b38806413b84bd20070f3c81b899e5b890b1a8b.zip | |
feat: Job queue inspector (#15856)
* wip
* wip
* Update job-queue.vue
* wip
* wip
* Update job-queue.vue
* wip
* Update job-queue.vue
* wip
* Update QueueService.ts
* Update QueueService.ts
* Update QueueService.ts
* Update job-queue.vue
* wip
* wip
* wip
* Update job-queue.vue
* wip
* Update MkTl.vue
* wip
* Update index.vue
* wip
* wip
* Update MkTl.vue
* 🎨
* jobs search
* wip
* Update job-queue.vue
* wip
* wip
* Update job-queue.vue
* Update job-queue.vue
* Update job-queue.vue
* Update job-queue.vue
* wip
* Update job-queue.job.vue
* wip
* wip
* wip
* Update MkCode.vue
* wip
* Update job-queue.job.vue
* wip
* Update job-queue.job.vue
* Update misskey-js.api.md
* Update CHANGELOG.md
* Update job-queue.job.vue
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/components/MkCode.vue | 8 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkFolder.vue | 46 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkTabs.vue | 235 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkTl.vue | 173 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue (renamed from packages/frontend/src/pages/admin/queue.chart.chart.vue) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/federation-job-queue.chart.vue (renamed from packages/frontend/src/pages/admin/queue.chart.vue) | 4 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/federation-job-queue.vue (renamed from packages/frontend/src/pages/admin/queue.vue) | 8 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/index.vue | 13 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/job-queue.chart.vue | 127 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/job-queue.job.vue | 280 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/job-queue.vue | 370 | ||||
| -rw-r--r-- | packages/frontend/src/router.definition.ts | 10 |
12 files changed, 1243 insertions, 31 deletions
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', |