LogsQueueView.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. <template>
  2. <Section
  3. :title="t('logs.queue-title')"
  4. :padding="false"
  5. >
  6. <template #toolbar>
  7. <router-link
  8. to="/logs"
  9. class="button neutral"
  10. >
  11. <svg
  12. xmlns="http://www.w3.org/2000/svg"
  13. width="1em"
  14. height="1em"
  15. viewBox="0 0 24 24"
  16. >
  17. <path
  18. fill="currentColor"
  19. d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"
  20. />
  21. </svg>
  22. {{ t('logs.back-to-list') }}
  23. </router-link>
  24. </template>
  25. <p class="padding">
  26. {{ t('logs.queue-page-description') }}
  27. </p>
  28. <div
  29. v-if="groups.length === 0 && !loading"
  30. class="empty-state padding"
  31. >
  32. <p>{{ t('logs.queue-empty') }}</p>
  33. <router-link to="/logs">
  34. {{ t('logs.back-to-list') }}
  35. </router-link>
  36. </div>
  37. </Section>
  38. <section
  39. v-for="actionGroup in groups"
  40. :key="actionGroup.name"
  41. class="with-header-and-content queue-action-group-section"
  42. >
  43. <div class="section-header flex-row">
  44. <div class="fg1 queue-action-group-heading">
  45. <ActionIconGlyph
  46. class="icon"
  47. :glyph="actionGroup.icon"
  48. />
  49. <h2>{{ displayActionGroupName(actionGroup.name) }}</h2>
  50. </div>
  51. <ActionGroupLimitsLabel
  52. :max-concurrent="actionGroup.maxConcurrent"
  53. :queue-size="actionGroup.queueSize"
  54. />
  55. </div>
  56. <div class="section-content">
  57. <table class="logs-table row-hover">
  58. <thead>
  59. <tr>
  60. <th>{{ t('logs.timestamp') }}</th>
  61. <th>{{ t('logs.action') }}</th>
  62. <th>{{ t('logs.metadata') }}</th>
  63. <th>{{ t('logs.status') }}</th>
  64. </tr>
  65. </thead>
  66. <tbody>
  67. <template
  68. v-for="action in actionGroup.actions"
  69. :key="`${actionGroup.name}:${action.bindingId}`"
  70. >
  71. <tr
  72. v-for="(entry, index) in action.entries"
  73. :key="entry.executionTrackingId"
  74. class="log-row"
  75. :title="action.actionTitle"
  76. >
  77. <td class="timestamp">
  78. {{ formatTimestamp(entry.datetimeStarted) }}
  79. </td>
  80. <td>
  81. <ActionIconGlyph
  82. class="icon"
  83. :glyph="action.actionIcon"
  84. />
  85. <router-link :to="`/logs/${entry.executionTrackingId}`">
  86. <LogActionTitle
  87. :action-title="action.actionTitle"
  88. :justification="entry.justification"
  89. />
  90. </router-link>
  91. <span
  92. v-if="action.entityPrefix"
  93. class="queue-entity annotation"
  94. >
  95. {{ action.entityPrefix }}
  96. </span>
  97. </td>
  98. <td class="tags">
  99. <span class="annotation">
  100. <span class="annotation-key">User:</span>
  101. <span class="annotation-val">{{ entry.user }}</span>
  102. </span>
  103. <span
  104. v-if="entry.tags && entry.tags.length > 0"
  105. class="tag-list"
  106. >
  107. <span
  108. v-for="tag in entry.tags"
  109. :key="tag"
  110. class="tag"
  111. >{{ tag }}</span>
  112. </span>
  113. </td>
  114. <td class="exit-code">
  115. <span
  116. v-if="!entry.executionFinished"
  117. class="queue-position"
  118. >
  119. {{ t('logs.queue-position', { position: index + 1 }) }}
  120. </span>
  121. <ActionStatusDisplay
  122. :log-entry="entry"
  123. link-queued-status
  124. />
  125. </td>
  126. </tr>
  127. </template>
  128. </tbody>
  129. </table>
  130. </div>
  131. </section>
  132. </template>
  133. <script setup>
  134. import { ref, onMounted, onUnmounted } from 'vue'
  135. import Section from 'picocrank/vue/components/Section.vue'
  136. import ActionIconGlyph from '../components/ActionIconGlyph.vue'
  137. import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
  138. import LogActionTitle from '../components/LogActionTitle.vue'
  139. import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
  140. import { useI18n } from 'vue-i18n'
  141. import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
  142. const defaultActionGroupName = 'default'
  143. const { t } = useI18n()
  144. const groups = ref([])
  145. const loading = ref(false)
  146. function displayActionGroupName (name) {
  147. if (name === defaultActionGroupName) {
  148. return t('logs.queue-default-group')
  149. }
  150. return name
  151. }
  152. function collectCompletedEntries (currentGroups) {
  153. const completed = []
  154. for (const group of currentGroups || []) {
  155. for (const action of group.actions || []) {
  156. for (const entry of action.entries || []) {
  157. if (entry.executionFinished) {
  158. completed.push(cloneLogEntry(entry))
  159. }
  160. }
  161. }
  162. }
  163. return completed
  164. }
  165. function sortGroupEntries (entries) {
  166. entries.sort((left, right) => {
  167. if (left.executionFinished !== right.executionFinished) {
  168. return left.executionFinished ? 1 : -1
  169. }
  170. return (left.datetimeStarted || '').localeCompare(right.datetimeStarted || '')
  171. })
  172. }
  173. function sortGroups (groupList) {
  174. groupList.sort((left, right) => {
  175. if (left.name === defaultActionGroupName) {
  176. return 1
  177. }
  178. if (right.name === defaultActionGroupName) {
  179. return -1
  180. }
  181. return (left.name || '').localeCompare(right.name || '')
  182. })
  183. }
  184. function sumActionEntries (actions) {
  185. return (actions || []).reduce((total, action) => total + (action.entries || []).length, 0)
  186. }
  187. function cloneActionGroup (group) {
  188. return {
  189. ...group,
  190. actions: (group.actions || []).map(action => ({
  191. ...action,
  192. entries: [...(action.entries || [])]
  193. }))
  194. }
  195. }
  196. function findActionInGroups (groupList, bindingId) {
  197. const matches = []
  198. for (const group of groupList) {
  199. for (const action of group.actions || []) {
  200. if (action.bindingId === bindingId) {
  201. matches.push({ group, action })
  202. }
  203. }
  204. }
  205. return matches
  206. }
  207. function mergeCompletedEntries (apiGroups, completedEntries) {
  208. const merged = (apiGroups || []).map(cloneActionGroup)
  209. for (const entry of completedEntries) {
  210. const alreadyPresent = merged.some(group =>
  211. (group.actions || []).some(action =>
  212. (action.entries || []).some(item => item.executionTrackingId === entry.executionTrackingId)
  213. )
  214. )
  215. if (alreadyPresent) {
  216. continue
  217. }
  218. const matches = findActionInGroups(merged, entry.bindingId)
  219. if (matches.length === 0) {
  220. continue
  221. }
  222. for (const { action } of matches) {
  223. action.entries.push(entry)
  224. }
  225. }
  226. for (const group of merged) {
  227. for (const action of group.actions || []) {
  228. sortGroupEntries(action.entries)
  229. action.activeCount = action.entries.length
  230. }
  231. refreshGroupCounts(group)
  232. }
  233. sortGroups(merged)
  234. return merged
  235. }
  236. function forEachActionWithEntry (groupList, executionTrackingId, callback) {
  237. for (const group of groupList) {
  238. for (const action of group.actions || []) {
  239. const hasEntry = (action.entries || []).some(
  240. item => item.executionTrackingId === executionTrackingId
  241. )
  242. if (hasEntry) {
  243. callback(group, action)
  244. }
  245. }
  246. }
  247. }
  248. function refreshGroupCounts (group) {
  249. let queued = 0
  250. for (const action of group.actions || []) {
  251. action.activeCount = (action.entries || []).length
  252. for (const entry of action.entries || []) {
  253. if (entry.queued) {
  254. queued++
  255. }
  256. }
  257. }
  258. group.activeCount = sumActionEntries(group.actions)
  259. group.queuedCount = queued
  260. }
  261. function applyQueueEntryUpdate (logEntry, afterUpdate) {
  262. const result = updateLogEntryInGroups(groups.value, logEntry)
  263. if (!result) {
  264. return false
  265. }
  266. if (afterUpdate) {
  267. afterUpdate(result)
  268. }
  269. forEachActionWithEntry(groups.value, logEntry.executionTrackingId, (group, action) => {
  270. sortGroupEntries(action.entries)
  271. refreshGroupCounts(group)
  272. })
  273. return true
  274. }
  275. function insertActiveQueueEntry (logEntry) {
  276. if (!logEntry?.bindingId || !logEntry.executionTrackingId || logEntry.executionFinished) {
  277. fetchQueue()
  278. return
  279. }
  280. const matches = findActionInGroups(groups.value, logEntry.bindingId)
  281. if (matches.length === 0) {
  282. fetchQueue()
  283. return
  284. }
  285. const touchedGroups = new Set()
  286. for (const { group, action } of matches) {
  287. action.entries.push(cloneLogEntry(logEntry))
  288. touchedGroups.add(group)
  289. sortGroupEntries(action.entries)
  290. }
  291. for (const group of touchedGroups) {
  292. refreshGroupCounts(group)
  293. }
  294. sortGroups(groups.value)
  295. }
  296. function onExecutionStarted (evt) {
  297. const logEntry = getExecutionLogEntry(evt)
  298. if (!logEntry) {
  299. return
  300. }
  301. if (!applyQueueEntryUpdate(logEntry)) {
  302. insertActiveQueueEntry(logEntry)
  303. }
  304. }
  305. function onExecutionFinished (evt) {
  306. const logEntry = getExecutionLogEntry(evt)
  307. if (!logEntry) {
  308. return
  309. }
  310. applyQueueEntryUpdate(logEntry)
  311. }
  312. function formatTimestamp (timestamp) {
  313. if (!timestamp) {
  314. return 'Unknown'
  315. }
  316. try {
  317. return new Date(timestamp).toLocaleString()
  318. } catch (err) {
  319. return timestamp
  320. }
  321. }
  322. async function fetchQueue () {
  323. loading.value = true
  324. try {
  325. const completedEntries = collectCompletedEntries(groups.value)
  326. const response = await window.client.getExecutionQueue({})
  327. groups.value = mergeCompletedEntries(response.groups || [], completedEntries)
  328. } catch (err) {
  329. console.error('Failed to fetch execution queue:', err)
  330. window.showBigError('fetch-queue', 'getting execution queue', err, false)
  331. } finally {
  332. loading.value = false
  333. }
  334. }
  335. onMounted(() => {
  336. fetchQueue()
  337. window.addEventListener('EventExecutionStarted', onExecutionStarted)
  338. window.addEventListener('EventExecutionFinished', onExecutionFinished)
  339. })
  340. onUnmounted(() => {
  341. window.removeEventListener('EventExecutionStarted', onExecutionStarted)
  342. window.removeEventListener('EventExecutionFinished', onExecutionFinished)
  343. })
  344. </script>
  345. <style scoped>
  346. .queue-action-group-heading {
  347. display: flex;
  348. align-items: center;
  349. gap: 0.5rem;
  350. min-width: 0;
  351. }
  352. .queue-action-group-heading h2 {
  353. margin: 0;
  354. }
  355. .timestamp {
  356. font-family: monospace;
  357. font-size: 0.875rem;
  358. color: #666;
  359. }
  360. .icon {
  361. margin-right: 0.5rem;
  362. font-size: 1.2em;
  363. }
  364. .annotation {
  365. font-weight: 500;
  366. font-size: smaller;
  367. }
  368. .queue-entity {
  369. display: block;
  370. margin-top: 0.25rem;
  371. color: #666;
  372. }
  373. .exit-code {
  374. display: flex;
  375. align-items: center;
  376. gap: 0.5rem;
  377. }
  378. .queue-position {
  379. white-space: nowrap;
  380. }
  381. .empty-state {
  382. text-align: center;
  383. padding: 2rem;
  384. color: #666;
  385. }
  386. .empty-state a {
  387. color: #007bff;
  388. text-decoration: none;
  389. }
  390. .empty-state a:hover {
  391. text-decoration: underline;
  392. }
  393. </style>