olivetin/frontend/resources/vue/views/LogsQueueView.vue

449 lines
11 KiB
Vue

<template>
<Section
:title="t('logs.queue-title')"
:padding="false"
>
<template #toolbar>
<router-link
to="/logs"
class="button neutral"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"
/>
</svg>
{{ t('logs.back-to-list') }}
</router-link>
</template>
<p class="padding">
{{ t('logs.queue-page-description') }}
</p>
<div
v-if="groups.length === 0 && !loading"
class="empty-state padding"
>
<p>{{ t('logs.queue-empty') }}</p>
<router-link to="/logs">
{{ t('logs.back-to-list') }}
</router-link>
</div>
</Section>
<section
v-for="actionGroup in groups"
:key="actionGroup.name"
class="with-header-and-content queue-action-group-section"
>
<div class="section-header flex-row">
<div class="fg1 queue-action-group-heading">
<ActionIconGlyph
class="icon"
:glyph="actionGroup.icon"
/>
<h2>{{ displayActionGroupName(actionGroup.name) }}</h2>
</div>
<ActionGroupLimitsLabel
:max-concurrent="actionGroup.maxConcurrent"
:queue-size="actionGroup.queueSize"
/>
</div>
<div class="section-content">
<table class="logs-table row-hover">
<thead>
<tr>
<th>{{ t('logs.timestamp') }}</th>
<th>{{ t('logs.action') }}</th>
<th>{{ t('logs.metadata') }}</th>
<th>{{ t('logs.status') }}</th>
</tr>
</thead>
<tbody>
<template
v-for="action in actionGroup.actions"
:key="`${actionGroup.name}:${action.bindingId}`"
>
<tr
v-for="(entry, index) in action.entries"
:key="entry.executionTrackingId"
class="log-row"
:title="action.actionTitle"
>
<td class="timestamp">
{{ formatTimestamp(entry.datetimeStarted) }}
</td>
<td>
<ActionIconGlyph
class="icon"
:glyph="action.actionIcon"
/>
<router-link :to="`/logs/${entry.executionTrackingId}`">
<LogActionTitle
:action-title="action.actionTitle"
:justification="entry.justification"
/>
</router-link>
<span
v-if="action.entityPrefix"
class="queue-entity annotation"
>
{{ action.entityPrefix }}
</span>
</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ entry.user }}</span>
</span>
<span
v-if="entry.tags && entry.tags.length > 0"
class="tag-list"
>
<span
v-for="tag in entry.tags"
:key="tag"
class="tag"
>{{ tag }}</span>
</span>
</td>
<td class="exit-code">
<span
v-if="!entry.executionFinished"
class="queue-position"
>
{{ t('logs.queue-position', { position: index + 1 }) }}
</span>
<ActionStatusDisplay
:log-entry="entry"
link-queued-status
/>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import LogActionTitle from '../components/LogActionTitle.vue'
import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
import { useI18n } from 'vue-i18n'
import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
const defaultActionGroupName = 'default'
const { t } = useI18n()
const groups = ref([])
const loading = ref(false)
function displayActionGroupName (name) {
if (name === defaultActionGroupName) {
return t('logs.queue-default-group')
}
return name
}
function collectCompletedEntries (currentGroups) {
const completed = []
for (const group of currentGroups || []) {
for (const action of group.actions || []) {
for (const entry of action.entries || []) {
if (entry.executionFinished) {
completed.push(cloneLogEntry(entry))
}
}
}
}
return completed
}
function sortGroupEntries (entries) {
entries.sort((left, right) => {
if (left.executionFinished !== right.executionFinished) {
return left.executionFinished ? 1 : -1
}
return (left.datetimeStarted || '').localeCompare(right.datetimeStarted || '')
})
}
function sortGroups (groupList) {
groupList.sort((left, right) => {
if (left.name === defaultActionGroupName) {
return 1
}
if (right.name === defaultActionGroupName) {
return -1
}
return (left.name || '').localeCompare(right.name || '')
})
}
function sumActionEntries (actions) {
return (actions || []).reduce((total, action) => total + (action.entries || []).length, 0)
}
function cloneActionGroup (group) {
return {
...group,
actions: (group.actions || []).map(action => ({
...action,
entries: [...(action.entries || [])]
}))
}
}
function findActionInGroups (groupList, bindingId) {
const matches = []
for (const group of groupList) {
for (const action of group.actions || []) {
if (action.bindingId === bindingId) {
matches.push({ group, action })
}
}
}
return matches
}
function mergeCompletedEntries (apiGroups, completedEntries) {
const merged = (apiGroups || []).map(cloneActionGroup)
for (const entry of completedEntries) {
const alreadyPresent = merged.some(group =>
(group.actions || []).some(action =>
(action.entries || []).some(item => item.executionTrackingId === entry.executionTrackingId)
)
)
if (alreadyPresent) {
continue
}
const matches = findActionInGroups(merged, entry.bindingId)
if (matches.length === 0) {
continue
}
for (const { action } of matches) {
action.entries.push(entry)
}
}
for (const group of merged) {
for (const action of group.actions || []) {
sortGroupEntries(action.entries)
action.activeCount = action.entries.length
}
refreshGroupCounts(group)
}
sortGroups(merged)
return merged
}
function forEachActionWithEntry (groupList, executionTrackingId, callback) {
for (const group of groupList) {
for (const action of group.actions || []) {
const hasEntry = (action.entries || []).some(
item => item.executionTrackingId === executionTrackingId
)
if (hasEntry) {
callback(group, action)
}
}
}
}
function refreshGroupCounts (group) {
let queued = 0
for (const action of group.actions || []) {
action.activeCount = (action.entries || []).length
for (const entry of action.entries || []) {
if (entry.queued) {
queued++
}
}
}
group.activeCount = sumActionEntries(group.actions)
group.queuedCount = queued
}
function applyQueueEntryUpdate (logEntry, afterUpdate) {
const result = updateLogEntryInGroups(groups.value, logEntry)
if (!result) {
return false
}
if (afterUpdate) {
afterUpdate(result)
}
forEachActionWithEntry(groups.value, logEntry.executionTrackingId, (group, action) => {
sortGroupEntries(action.entries)
refreshGroupCounts(group)
})
return true
}
function insertActiveQueueEntry (logEntry) {
if (!logEntry?.bindingId || !logEntry.executionTrackingId || logEntry.executionFinished) {
fetchQueue()
return
}
const matches = findActionInGroups(groups.value, logEntry.bindingId)
if (matches.length === 0) {
fetchQueue()
return
}
const touchedGroups = new Set()
for (const { group, action } of matches) {
action.entries.push(cloneLogEntry(logEntry))
touchedGroups.add(group)
sortGroupEntries(action.entries)
}
for (const group of touchedGroups) {
refreshGroupCounts(group)
}
sortGroups(groups.value)
}
function onExecutionStarted (evt) {
const logEntry = getExecutionLogEntry(evt)
if (!logEntry) {
return
}
if (!applyQueueEntryUpdate(logEntry)) {
insertActiveQueueEntry(logEntry)
}
}
function onExecutionFinished (evt) {
const logEntry = getExecutionLogEntry(evt)
if (!logEntry) {
return
}
applyQueueEntryUpdate(logEntry)
}
function formatTimestamp (timestamp) {
if (!timestamp) {
return 'Unknown'
}
try {
return new Date(timestamp).toLocaleString()
} catch (err) {
return timestamp
}
}
async function fetchQueue () {
loading.value = true
try {
const completedEntries = collectCompletedEntries(groups.value)
const response = await window.client.getExecutionQueue({})
groups.value = mergeCompletedEntries(response.groups || [], completedEntries)
} catch (err) {
console.error('Failed to fetch execution queue:', err)
window.showBigError('fetch-queue', 'getting execution queue', err, false)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchQueue()
window.addEventListener('EventExecutionStarted', onExecutionStarted)
window.addEventListener('EventExecutionFinished', onExecutionFinished)
})
onUnmounted(() => {
window.removeEventListener('EventExecutionStarted', onExecutionStarted)
window.removeEventListener('EventExecutionFinished', onExecutionFinished)
})
</script>
<style scoped>
.queue-action-group-heading {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.queue-action-group-heading h2 {
margin: 0;
}
.timestamp {
font-family: monospace;
font-size: 0.875rem;
color: #666;
}
.icon {
margin-right: 0.5rem;
font-size: 1.2em;
}
.annotation {
font-weight: 500;
font-size: smaller;
}
.queue-entity {
display: block;
margin-top: 0.25rem;
color: #666;
}
.exit-code {
display: flex;
align-items: center;
gap: 0.5rem;
}
.queue-position {
white-space: nowrap;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #666;
}
.empty-state a {
color: #007bff;
text-decoration: none;
}
.empty-state a:hover {
text-decoration: underline;
}
</style>