AdminScheduler.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. <template lang='pug'>
  2. q-page.admin-terminal
  3. .row.q-pa-md.items-center
  4. .col-auto
  5. img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-bot.svg')
  6. .col.q-pl-md
  7. .text-h5.text-primary.animated.fadeInLeft {{ t('admin.scheduler.title') }}
  8. .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.scheduler.subtitle') }}
  9. .col-auto.flex
  10. q-btn-toggle.q-mr-md(
  11. v-model='state.displayMode'
  12. push
  13. no-caps
  14. :disable='state.loading > 0'
  15. :toggle-color='$q.dark.isActive ? `white` : `black`'
  16. :toggle-text-color='$q.dark.isActive ? `black` : `white`'
  17. :text-color='$q.dark.isActive ? `white` : `black`'
  18. :color='$q.dark.isActive ? `dark-1` : `white`'
  19. :options=`[
  20. { label: t('admin.scheduler.schedule'), value: 'scheduled' },
  21. { label: t('admin.scheduler.upcoming'), value: 'upcoming' },
  22. { label: t('admin.scheduler.active'), value: 'active' },
  23. { label: t('admin.scheduler.completed'), value: 'completed' },
  24. { label: t('admin.scheduler.failed'), value: 'failed' },
  25. ]`
  26. )
  27. q-separator.q-mr-md(vertical)
  28. q-btn.q-mr-sm.acrylic-btn(
  29. icon='las la-question-circle'
  30. flat
  31. color='grey'
  32. :href='siteStore.docsBase + `/admin/scheduler`'
  33. target='_blank'
  34. type='a'
  35. )
  36. q-btn.q-mr-sm.acrylic-btn(
  37. icon='las la-redo-alt'
  38. flat
  39. color='secondary'
  40. :loading='state.loading > 0'
  41. @click='load'
  42. )
  43. q-separator(inset)
  44. .q-pa-md.q-gutter-md
  45. template(v-if='state.displayMode === `scheduled`')
  46. q-card.rounded-borders(
  47. v-if='state.scheduledJobs.length < 1'
  48. flat
  49. :class='$q.dark.isActive ? `bg-dark-5 text-white` : `bg-grey-3 text-dark`'
  50. )
  51. q-card-section.items-center(horizontal)
  52. q-card-section.col-auto.q-pr-none
  53. q-icon(name='las la-info-circle', size='sm')
  54. q-card-section.text-caption {{ t('admin.scheduler.scheduledNone') }}
  55. q-card.shadow-1(v-else)
  56. q-table(
  57. :rows='state.scheduledJobs'
  58. :columns='scheduledJobsHeaders'
  59. row-key='name'
  60. flat
  61. hide-bottom
  62. :rows-per-page-options='[0]'
  63. :loading='state.loading > 0'
  64. )
  65. template(v-slot:body-cell-id='props')
  66. q-td(:props='props')
  67. q-spinner-clock.q-mr-sm(
  68. color='indigo'
  69. size='xs'
  70. )
  71. template(v-slot:body-cell-task='props')
  72. q-td(:props='props')
  73. strong {{props.value}}
  74. div: small.text-grey {{props.row.id}}
  75. template(v-slot:body-cell-cron='props')
  76. q-td(:props='props')
  77. q-chip(
  78. square
  79. size='md'
  80. color='blue'
  81. text-color='white'
  82. )
  83. span.font-robotomono {{ props.value }}
  84. template(v-slot:body-cell-type='props')
  85. q-td(:props='props')
  86. q-chip(
  87. square
  88. size='md'
  89. dense
  90. color='deep-orange'
  91. text-color='white'
  92. )
  93. small.text-uppercase {{ props.value }}
  94. template(v-slot:body-cell-created='props')
  95. q-td(:props='props')
  96. span {{props.value}}
  97. div: small.text-grey {{humanizeDate(props.row.createdAt)}}
  98. template(v-slot:body-cell-updated='props')
  99. q-td(:props='props')
  100. span {{props.value}}
  101. div: small.text-grey {{humanizeDate(props.row.updatedAt)}}
  102. template(v-else-if='state.displayMode === `upcoming`')
  103. q-card.rounded-borders(
  104. v-if='state.upcomingJobs.length < 1'
  105. flat
  106. :class='$q.dark.isActive ? `bg-dark-5 text-white` : `bg-grey-3 text-dark`'
  107. )
  108. q-card-section.items-center(horizontal)
  109. q-card-section.col-auto.q-pr-none
  110. q-icon(name='las la-info-circle', size='sm')
  111. q-card-section.text-caption {{ t('admin.scheduler.upcomingNone') }}
  112. q-card.shadow-1(v-else)
  113. q-table(
  114. :rows='state.upcomingJobs'
  115. :columns='upcomingJobsHeaders'
  116. row-key='name'
  117. flat
  118. hide-bottom
  119. :rows-per-page-options='[0]'
  120. :loading='state.loading > 0'
  121. )
  122. template(v-slot:body-cell-id='props')
  123. q-td(:props='props')
  124. q-icon(name='las la-clock', color='primary', size='sm')
  125. template(v-slot:body-cell-task='props')
  126. q-td(:props='props')
  127. strong {{props.value}}
  128. div: small.text-grey {{props.row.id}}
  129. template(v-slot:body-cell-waituntil='props')
  130. q-td(:props='props')
  131. span {{ props.value }}
  132. div: small.text-grey {{humanizeDate(props.row.waitUntil)}}
  133. template(v-slot:body-cell-retries='props')
  134. q-td(:props='props')
  135. span #[strong {{props.value + 1}}] #[span.text-grey / {{props.row.maxRetries}}]
  136. template(v-slot:body-cell-useworker='props')
  137. q-td(:props='props')
  138. template(v-if='props.value')
  139. q-icon(name='las la-microchip', color='brown', size='sm')
  140. small.q-ml-xs.text-brown Worker
  141. template(v-else)
  142. q-icon(name='las la-leaf', color='teal', size='sm')
  143. small.q-ml-xs.text-teal In-Process
  144. template(v-slot:body-cell-date='props')
  145. q-td(:props='props')
  146. span {{props.value}}
  147. div
  148. i18n-t.text-grey(keypath='admin.scheduler.createdBy', tag='small')
  149. template(#instance)
  150. strong {{props.row.createdBy}}
  151. template(v-else)
  152. q-card.rounded-borders(
  153. v-if='state.jobs.length < 1'
  154. flat
  155. :class='$q.dark.isActive ? `bg-dark-5 text-white` : `bg-grey-3 text-dark`'
  156. )
  157. q-card-section.items-center(horizontal)
  158. q-card-section.col-auto.q-pr-none
  159. q-icon(name='las la-info-circle', size='sm')
  160. q-card-section.text-caption {{ t('admin.scheduler.' + state.displayMode + 'None') }}
  161. q-card.shadow-1(v-else)
  162. q-table(
  163. :rows='state.jobs'
  164. :columns='jobsHeaders'
  165. row-key='name'
  166. flat
  167. hide-bottom
  168. :rows-per-page-options='[0]'
  169. :loading='state.loading > 0'
  170. )
  171. template(v-slot:body-cell-id='props')
  172. q-td(:props='props')
  173. q-avatar(
  174. v-if='props.row.state === `completed`'
  175. icon='las la-check'
  176. color='positive'
  177. text-color='white'
  178. size='sm'
  179. rounded
  180. )
  181. q-avatar(
  182. v-else-if='props.row.state === `failed`'
  183. icon='las la-times'
  184. color='negative'
  185. text-color='white'
  186. size='sm'
  187. rounded
  188. )
  189. q-avatar(
  190. v-else-if='props.row.state === `interrupted`'
  191. icon='las la-square-full'
  192. color='orange'
  193. text-color='white'
  194. size='sm'
  195. rounded
  196. )
  197. q-circular-progress(
  198. v-else-if='props.row.state === `active`'
  199. indeterminate
  200. size='sm'
  201. :thickness='0.4'
  202. color='blue'
  203. track-color='blue-1'
  204. center-color='blue-2'
  205. )
  206. template(v-slot:body-cell-task='props')
  207. q-td(:props='props')
  208. strong {{props.value}}
  209. div: small.text-grey {{props.row.id}}
  210. template(v-slot:body-cell-state='props')
  211. q-td(:props='props')
  212. template(v-if='props.value === `completed`')
  213. i18n-t(keypath='admin.scheduler.completedIn', tag='span')
  214. template(#duration)
  215. strong {{humanizeDuration(props.row.startedAt, props.row.completedAt)}}
  216. div: small.text-grey {{ humanizeDate(props.row.completedAt) }}
  217. template(v-else-if='props.value === `active`')
  218. em.text-grey {{ t('admin.scheduler.pending') }}
  219. template(v-else)
  220. strong.text-negative {{ props.value === 'failed' ? t('admin.scheduler.error') : t('admin.scheduler.interrupted') }}
  221. div: small {{ props.row.lastErrorMessage }}
  222. template(v-slot:body-cell-attempt='props')
  223. q-td(:props='props')
  224. span #[strong {{props.value}}] #[span.text-grey / {{props.row.maxRetries}}]
  225. template(v-slot:body-cell-useworker='props')
  226. q-td(:props='props')
  227. template(v-if='props.value')
  228. q-icon(name='las la-microchip', color='brown', size='sm')
  229. small.q-ml-xs.text-brown Worker
  230. template(v-else)
  231. q-icon(name='las la-leaf', color='teal', size='sm')
  232. small.q-ml-xs.text-teal In-Process
  233. template(v-slot:body-cell-date='props')
  234. q-td(:props='props')
  235. span {{props.value}}
  236. div: small.text-grey {{humanizeDate(props.row.startedAt)}}
  237. div
  238. i18n-t.text-grey(keypath='admin.scheduler.createdBy', tag='small')
  239. template(#instance)
  240. strong {{props.row.executedBy}}
  241. </template>
  242. <script setup>
  243. import { onMounted, reactive, watch } from 'vue'
  244. import { useMeta, useQuasar } from 'quasar'
  245. import { useI18n } from 'vue-i18n'
  246. import gql from 'graphql-tag'
  247. import { DateTime, Duration, Interval } from 'luxon'
  248. import { useSiteStore } from 'src/stores/site'
  249. // QUASAR
  250. const $q = useQuasar()
  251. // STORES
  252. const siteStore = useSiteStore()
  253. // I18N
  254. const { t } = useI18n()
  255. // META
  256. useMeta({
  257. title: t('admin.scheduler.title')
  258. })
  259. // DATA
  260. const state = reactive({
  261. displayMode: 'completed',
  262. scheduledJobs: [],
  263. upcomingJobs: [],
  264. jobs: [],
  265. loading: 0
  266. })
  267. const scheduledJobsHeaders = [
  268. {
  269. align: 'center',
  270. field: 'id',
  271. name: 'id',
  272. sortable: false,
  273. style: 'width: 15px; padding-right: 0;'
  274. },
  275. {
  276. label: t('common.field.task'),
  277. align: 'left',
  278. field: 'task',
  279. name: 'task',
  280. sortable: true
  281. },
  282. {
  283. label: t('admin.scheduler.cron'),
  284. align: 'left',
  285. field: 'cron',
  286. name: 'cron',
  287. sortable: true
  288. },
  289. {
  290. label: t('admin.scheduler.type'),
  291. align: 'left',
  292. field: 'type',
  293. name: 'type',
  294. sortable: true
  295. },
  296. {
  297. label: t('admin.scheduler.createdAt'),
  298. align: 'left',
  299. field: 'createdAt',
  300. name: 'created',
  301. sortable: true,
  302. format: v => DateTime.fromISO(v).toRelative()
  303. },
  304. {
  305. label: t('admin.scheduler.updatedAt'),
  306. align: 'left',
  307. field: 'updatedAt',
  308. name: 'updated',
  309. sortable: true,
  310. format: v => DateTime.fromISO(v).toRelative()
  311. }
  312. ]
  313. const upcomingJobsHeaders = [
  314. {
  315. align: 'center',
  316. field: 'id',
  317. name: 'id',
  318. sortable: false,
  319. style: 'width: 15px; padding-right: 0;'
  320. },
  321. {
  322. label: t('common.field.task'),
  323. align: 'left',
  324. field: 'task',
  325. name: 'task',
  326. sortable: true
  327. },
  328. {
  329. label: t('admin.scheduler.waitUntil'),
  330. align: 'left',
  331. field: 'waitUntil',
  332. name: 'waituntil',
  333. sortable: true,
  334. format: v => DateTime.fromISO(v).toRelative()
  335. },
  336. {
  337. label: t('admin.scheduler.attempt'),
  338. align: 'left',
  339. field: 'retries',
  340. name: 'retries',
  341. sortable: true
  342. },
  343. {
  344. label: t('admin.scheduler.useWorker'),
  345. align: 'left',
  346. field: 'useWorker',
  347. name: 'useworker',
  348. sortable: true
  349. },
  350. {
  351. label: t('admin.scheduler.scheduled'),
  352. align: 'left',
  353. field: 'createdAt',
  354. name: 'date',
  355. sortable: true,
  356. format: v => DateTime.fromISO(v).toRelative()
  357. }
  358. ]
  359. const jobsHeaders = [
  360. {
  361. align: 'center',
  362. field: 'id',
  363. name: 'id',
  364. sortable: false,
  365. style: 'width: 15px; padding-right: 0;'
  366. },
  367. {
  368. label: t('common.field.task'),
  369. align: 'left',
  370. field: 'task',
  371. name: 'task',
  372. sortable: true
  373. },
  374. {
  375. label: t('admin.scheduler.result'),
  376. align: 'left',
  377. field: 'state',
  378. name: 'state',
  379. sortable: true
  380. },
  381. {
  382. label: t('admin.scheduler.attempt'),
  383. align: 'left',
  384. field: 'attempt',
  385. name: 'attempt',
  386. sortable: true
  387. },
  388. {
  389. label: t('admin.scheduler.useWorker'),
  390. align: 'left',
  391. field: 'useWorker',
  392. name: 'useworker',
  393. sortable: true
  394. },
  395. {
  396. label: t('admin.scheduler.startedAt'),
  397. align: 'left',
  398. field: 'startedAt',
  399. name: 'date',
  400. sortable: true,
  401. format: v => DateTime.fromISO(v).toRelative()
  402. }
  403. ]
  404. // WATCHERS
  405. watch(() => state.displayMode, (newValue) => {
  406. load()
  407. })
  408. // METHODS
  409. function humanizeDate (val) {
  410. return DateTime.fromISO(val).toFormat('fff')
  411. }
  412. function humanizeDuration (start, end) {
  413. const dur = Interval.fromDateTimes(DateTime.fromISO(start), DateTime.fromISO(end))
  414. .toDuration(['hours', 'minutes', 'seconds', 'milliseconds'])
  415. return Duration.fromObject({
  416. ...dur.hours > 0 && { hours: dur.hours },
  417. ...dur.minutes > 0 && { minutes: dur.minutes },
  418. ...dur.seconds > 0 && { seconds: dur.seconds },
  419. ...dur.milliseconds > 0 && { milliseconds: dur.milliseconds }
  420. }).toHuman({ unitDisplay: 'narrow', listStyle: 'short' })
  421. }
  422. async function load () {
  423. state.loading++
  424. try {
  425. if (state.displayMode === 'scheduled') {
  426. const resp = await APOLLO_CLIENT.query({
  427. query: gql`
  428. query getSystemJobsScheduled {
  429. systemJobsScheduled {
  430. id
  431. task
  432. cron
  433. type
  434. createdAt
  435. updatedAt
  436. }
  437. }
  438. `,
  439. fetchPolicy: 'network-only'
  440. })
  441. state.scheduledJobs = resp?.data?.systemJobsScheduled
  442. } else if (state.displayMode === 'upcoming') {
  443. const resp = await APOLLO_CLIENT.query({
  444. query: gql`
  445. query getSystemJobsUpcoming {
  446. systemJobsUpcoming {
  447. id
  448. task
  449. useWorker
  450. retries
  451. maxRetries
  452. waitUntil
  453. isScheduled
  454. createdBy
  455. createdAt
  456. updatedAt
  457. }
  458. }
  459. `,
  460. fetchPolicy: 'network-only'
  461. })
  462. state.upcomingJobs = resp?.data?.systemJobsUpcoming
  463. } else {
  464. const states = state.displayMode === 'failed' ? ['FAILED', 'INTERRUPTED'] : [state.displayMode.toUpperCase()]
  465. const resp = await APOLLO_CLIENT.query({
  466. query: gql`
  467. query getSystemJobs (
  468. $states: [SystemJobState]
  469. ) {
  470. systemJobs (
  471. states: $states
  472. ) {
  473. id
  474. task
  475. state
  476. useWorker
  477. wasScheduled
  478. attempt
  479. maxRetries
  480. lastErrorMessage
  481. executedBy
  482. createdAt
  483. startedAt
  484. completedAt
  485. }
  486. }
  487. `,
  488. variables: {
  489. states
  490. },
  491. fetchPolicy: 'network-only'
  492. })
  493. state.jobs = resp?.data?.systemJobs?.map(j => ({ ...j, state: j.state.toLowerCase() }))
  494. }
  495. } catch (err) {
  496. $q.notify({
  497. type: 'negative',
  498. message: 'Failed to load scheduled jobs.',
  499. caption: err.message
  500. })
  501. }
  502. state.loading--
  503. }
  504. // MOUNTED
  505. onMounted(() => {
  506. load()
  507. })
  508. </script>