fix: action group display fixes

This commit is contained in:
jamesread 2026-06-18 11:55:14 +01:00
parent de68ba7433
commit 03a9a05e38
16 changed files with 967 additions and 553 deletions

View File

@ -834,9 +834,9 @@ export declare type GetExecutionQueueRequest = Message<"olivetin.api.v1.GetExecu
export declare const GetExecutionQueueRequestSchema: GenMessage<GetExecutionQueueRequest>; export declare const GetExecutionQueueRequestSchema: GenMessage<GetExecutionQueueRequest>;
/** /**
* @generated from message olivetin.api.v1.ExecutionQueueGroup * @generated from message olivetin.api.v1.ExecutionQueueAction
*/ */
export declare type ExecutionQueueGroup = Message<"olivetin.api.v1.ExecutionQueueGroup"> & { export declare type ExecutionQueueAction = Message<"olivetin.api.v1.ExecutionQueueAction"> & {
/** /**
* @generated from field: string binding_id = 1; * @generated from field: string binding_id = 1;
*/ */
@ -873,6 +873,47 @@ export declare type ExecutionQueueGroup = Message<"olivetin.api.v1.ExecutionQueu
entries: LogEntry[]; entries: LogEntry[];
}; };
/**
* Describes the message olivetin.api.v1.ExecutionQueueAction.
* Use `create(ExecutionQueueActionSchema)` to create a new message.
*/
export declare const ExecutionQueueActionSchema: GenMessage<ExecutionQueueAction>;
/**
* @generated from message olivetin.api.v1.ExecutionQueueGroup
*/
export declare type ExecutionQueueGroup = Message<"olivetin.api.v1.ExecutionQueueGroup"> & {
/**
* @generated from field: string name = 1;
*/
name: string;
/**
* @generated from field: string icon = 2;
*/
icon: string;
/**
* @generated from field: int32 max_concurrent = 3;
*/
maxConcurrent: number;
/**
* @generated from field: int32 active_count = 4;
*/
activeCount: number;
/**
* @generated from field: repeated olivetin.api.v1.ExecutionQueueAction actions = 5;
*/
actions: ExecutionQueueAction[];
/**
* @generated from field: int32 queued_count = 6;
*/
queuedCount: number;
};
/** /**
* Describes the message olivetin.api.v1.ExecutionQueueGroup. * Describes the message olivetin.api.v1.ExecutionQueueGroup.
* Use `create(ExecutionQueueGroupSchema)` to create a new message. * Use `create(ExecutionQueueGroupSchema)` to create a new message.

File diff suppressed because one or more lines are too long

View File

@ -36,8 +36,11 @@ export function updateLogEntryInGroups (groups, logEntry) {
return null return null
} }
let firstMatch = null
for (const group of groups) { for (const group of groups) {
const entries = group.entries || [] for (const action of group.actions || []) {
const entries = action.entries || []
const index = entries.findIndex( const index = entries.findIndex(
item => item.executionTrackingId === entry.executionTrackingId item => item.executionTrackingId === entry.executionTrackingId
) )
@ -47,8 +50,11 @@ export function updateLogEntryInGroups (groups, logEntry) {
const previous = entries[index] const previous = entries[index]
entries[index] = entry entries[index] = entry
return { group, index, previous } if (!firstMatch) {
firstMatch = { group, action, index, previous }
}
}
} }
return null return firstMatch
} }

View File

@ -18,52 +18,54 @@
</Section> </Section>
<section <section
v-for="group in groups" v-for="actionGroup in groups"
:key="group.bindingId" :key="actionGroup.name"
class="with-header-and-content queue-group-section" class="with-header-and-content queue-action-group-section"
> >
<div class="section-header flex-row"> <div class="section-header flex-row">
<div class="fg1 queue-group-heading"> <div class="fg1 queue-action-group-heading">
<ActionIconGlyph class="queue-group-icon" :glyph="group.actionIcon" /> <ActionIconGlyph class="icon" :glyph="actionGroup.icon" />
<div class="queue-group-title"> <h2>{{ displayActionGroupName(actionGroup.name) }}</h2>
<h2 :title="group.entityPrefix ? `${t('logs.queue-entity')}: ${group.entityPrefix}` : ''">
{{ group.actionTitle }}
</h2>
<p v-if="group.entityPrefix" class="queue-entity">
{{ t('logs.queue-entity') }}: {{ group.entityPrefix }}
</p>
</div> </div>
</div> <span
<div role="toolbar" class="queue-group-toolbar"> v-if="actionGroup.maxConcurrent > 0"
<router-link class="queue-action-group-limit annotation"
v-if="group.bindingId"
:to="`/action/${group.bindingId}`"
class="button neutral"
:title="t('logs.queue-action-details')"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"> {{ t('logs.queue-group-limit', { max: actionGroup.maxConcurrent, queued: actionGroup.queuedCount }) }}
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.31-8.86c-1.77-.45-2.34-.94-2.34-1.67 0-.84.79-1.43 2.1-1.43 1.38 0 1.9.66 1.94 1.64h1.71c-.05-1.34-.87-2.57-2.49-2.97V5H10.9v1.69c-1.51.32-2.72 1.3-2.72 2.81 0 1.79 1.49 2.69 3.66 3.21 1.95.46 2.34 1.22 2.34 1.8 0 .53-.39 1.39-2.1 1.39-1.6 0-2.05-.56-2.13-1.45H8.04c.08 1.5 1.18 2.37 2.82 2.69V19h2.34v-1.63c1.65-.35 2.48-1.24 2.48-2.77-.01-1.88-1.51-2.87-3.7-3.23z"/>
</svg>
{{ t('logs.queue-action-details') }}
</router-link>
<span class="queue-group-limit annotation">
{{ t('logs.queue-group-active', { active: group.activeCount, max: group.maxConcurrent }) }}
</span> </span>
</div> </div>
</div>
<div class="section-content"> <div class="section-content">
<table class="logs-table row-hover"> <table class="logs-table row-hover">
<thead> <thead>
<tr> <tr>
<th>{{ t('logs.timestamp') }}</th> <th>{{ t('logs.timestamp') }}</th>
<th>{{ t('logs.action') }}</th>
<th>{{ t('logs.metadata') }}</th> <th>{{ t('logs.metadata') }}</th>
<th>{{ t('logs.status') }}</th> <th>{{ t('logs.status') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(entry, index) in group.entries" :key="entry.executionTrackingId" class="log-row"> <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 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"> <td class="tags">
<span class="annotation"> <span class="annotation">
<span class="annotation-key">User:</span> <span class="annotation-key">User:</span>
@ -72,18 +74,15 @@
<span v-if="entry.tags && entry.tags.length > 0" class="tag-list"> <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 v-for="tag in entry.tags" :key="tag" class="tag">{{ tag }}</span>
</span> </span>
<span class="annotation">
<span class="annotation-key">ID:</span>
<router-link :to="`/logs/${entry.executionTrackingId}`">
{{ entry.executionTrackingId }}
</router-link>
</span>
</td> </td>
<td class="exit-code"> <td class="exit-code">
<span v-if="!entry.executionFinished" class="queue-position">{{ t('logs.queue-position', { position: index + 1 }) }}</span> <span v-if="!entry.executionFinished" class="queue-position">
<ActionStatusDisplay :logEntry="entry" /> {{ t('logs.queue-position', { position: index + 1 }) }}
</span>
<ActionStatusDisplay :logEntry="entry" link-queued-status />
</td> </td>
</tr> </tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -95,23 +94,36 @@ import { ref, onMounted, onUnmounted } from 'vue'
import Section from 'picocrank/vue/components/Section.vue' import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue' import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue' import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import LogActionTitle from '../components/LogActionTitle.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js' import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
const defaultActionGroupName = 'default'
const { t } = useI18n() const { t } = useI18n()
const groups = ref([]) const groups = ref([])
const loading = ref(false) const loading = ref(false)
function displayActionGroupName (name) {
if (name === defaultActionGroupName) {
return t('logs.queue-default-group')
}
return name
}
function collectCompletedEntries (currentGroups) { function collectCompletedEntries (currentGroups) {
const completed = [] const completed = []
for (const group of currentGroups || []) { for (const group of currentGroups || []) {
for (const entry of group.entries || []) { for (const action of group.actions || []) {
for (const entry of action.entries || []) {
if (entry.executionFinished) { if (entry.executionFinished) {
completed.push(cloneLogEntry(entry)) completed.push(cloneLogEntry(entry))
} }
} }
} }
}
return completed return completed
} }
@ -126,53 +138,108 @@ function sortGroupEntries (entries) {
function sortGroups (groupList) { function sortGroups (groupList) {
groupList.sort((left, right) => { groupList.sort((left, right) => {
const byTitle = (left.actionTitle || '').localeCompare(right.actionTitle || '') if (left.name === defaultActionGroupName) {
if (byTitle !== 0) { return 1
return byTitle
} }
return (left.entityPrefix || '').localeCompare(right.entityPrefix || '') if (right.name === defaultActionGroupName) {
return -1
}
return (left.name || '').localeCompare(right.name || '')
}) })
} }
function mergeCompletedEntries (apiGroups, completedEntries) { function sumActionEntries (actions) {
const merged = (apiGroups || []).map(group => ({ return (actions || []).reduce((total, action) => total + (action.entries || []).length, 0)
}
function cloneActionGroup (group) {
return {
...group, ...group,
entries: [...(group.entries || [])] 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) { for (const entry of completedEntries) {
const alreadyPresent = merged.some(group => const alreadyPresent = merged.some(group =>
group.entries.some(item => item.executionTrackingId === entry.executionTrackingId) (group.actions || []).some(action =>
(action.entries || []).some(item => item.executionTrackingId === entry.executionTrackingId)
)
) )
if (alreadyPresent) { if (alreadyPresent) {
continue continue
} }
let group = merged.find(item => item.bindingId === entry.bindingId) const matches = findActionInGroups(merged, entry.bindingId)
if (!group) { if (matches.length === 0) {
group = { continue
bindingId: entry.bindingId,
actionTitle: entry.actionTitle,
actionIcon: entry.actionIcon,
entityPrefix: '',
maxConcurrent: 0,
activeCount: 0,
entries: []
}
merged.push(group)
} }
group.entries.push(entry) for (const { action } of matches) {
action.entries.push(entry)
}
} }
for (const group of merged) { for (const group of merged) {
sortGroupEntries(group.entries) for (const action of group.actions || []) {
sortGroupEntries(action.entries)
action.activeCount = action.entries.length
}
refreshGroupCounts(group)
} }
sortGroups(merged) sortGroups(merged)
return 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) { function applyQueueEntryUpdate (logEntry, afterUpdate) {
const result = updateLogEntryInGroups(groups.value, logEntry) const result = updateLogEntryInGroups(groups.value, logEntry)
if (!result) { if (!result) {
@ -182,16 +249,13 @@ function applyQueueEntryUpdate (logEntry, afterUpdate) {
if (afterUpdate) { if (afterUpdate) {
afterUpdate(result) afterUpdate(result)
} }
sortGroupEntries(result.group.entries)
return true
}
function adjustActiveCountOnStart (group, previous, logEntry) { forEachActionWithEntry(groups.value, logEntry.executionTrackingId, (group, action) => {
const wasActive = !previous.executionFinished sortGroupEntries(action.entries)
const isActive = !logEntry.executionFinished refreshGroupCounts(group)
if (!wasActive && isActive) { })
group.activeCount++
} return true
} }
function insertActiveQueueEntry (logEntry) { function insertActiveQueueEntry (logEntry) {
@ -200,23 +264,22 @@ function insertActiveQueueEntry (logEntry) {
return return
} }
let group = groups.value.find(item => item.bindingId === logEntry.bindingId) const matches = findActionInGroups(groups.value, logEntry.bindingId)
if (!group) { if (matches.length === 0) {
group = { fetchQueue()
bindingId: logEntry.bindingId, return
actionTitle: logEntry.actionTitle || '',
actionIcon: logEntry.actionIcon || '',
entityPrefix: logEntry.entityPrefix || '',
maxConcurrent: 0,
activeCount: 0,
entries: []
}
groups.value.push(group)
} }
group.entries.push(cloneLogEntry(logEntry)) const touchedGroups = new Set()
adjustActiveCountOnStart(group, { executionFinished: true }, logEntry) for (const { group, action } of matches) {
sortGroupEntries(group.entries) action.entries.push(cloneLogEntry(logEntry))
touchedGroups.add(group)
sortGroupEntries(action.entries)
}
for (const group of touchedGroups) {
refreshGroupCounts(group)
}
sortGroups(groups.value) sortGroups(groups.value)
} }
@ -226,9 +289,7 @@ function onExecutionStarted (evt) {
return return
} }
if (!applyQueueEntryUpdate(logEntry, ({ group, previous }) => { if (!applyQueueEntryUpdate(logEntry)) {
adjustActiveCountOnStart(group, previous, logEntry)
})) {
insertActiveQueueEntry(logEntry) insertActiveQueueEntry(logEntry)
} }
} }
@ -239,12 +300,7 @@ function onExecutionFinished (evt) {
return return
} }
applyQueueEntryUpdate(logEntry, ({ group, previous }) => { applyQueueEntryUpdate(logEntry)
const wasActive = !previous.executionFinished
if (wasActive && logEntry.executionFinished && group.activeCount > 0) {
group.activeCount--
}
})
} }
function formatTimestamp (timestamp) { function formatTimestamp (timestamp) {
@ -285,50 +341,43 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.queue-group-heading { .queue-action-group-heading {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
min-width: 0; min-width: 0;
} }
.queue-group-title h2 { .queue-action-group-heading h2 {
margin: 0; margin: 0;
} }
.queue-group-toolbar { .queue-action-group-limit {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.queue-group-limit {
white-space: nowrap; white-space: nowrap;
} }
.queue-entity {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: #666;
}
.queue-group-icon {
font-size: 1.5em;
flex-shrink: 0;
}
.timestamp { .timestamp {
font-family: monospace; font-family: monospace;
font-size: 0.875rem; font-size: 0.875rem;
color: #666; color: #666;
} }
.icon {
margin-right: 0.5rem;
font-size: 1.2em;
}
.annotation { .annotation {
font-weight: 500; font-weight: 500;
font-size: smaller; font-size: smaller;
} }
.queue-entity {
display: block;
margin-top: 0.25rem;
color: #666;
}
.exit-code { .exit-code {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -44,10 +44,13 @@
"logs.no-logs-to-display": "Es gibt keine Protokolle zu anzeigen.", "logs.no-logs-to-display": "Es gibt keine Protokolle zu anzeigen.",
"logs.page-description": "Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.", "logs.page-description": "Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.",
"logs.queue": "Warteschlange", "logs.queue": "Warteschlange",
"logs.queue-default-group": "Standard",
"logs.queue-empty": "Derzeit gibt es keine aktiven oder wartenden Ausführungen.", "logs.queue-empty": "Derzeit gibt es keine aktiven oder wartenden Ausführungen.",
"logs.queue-entity": "Entität", "logs.queue-entity": "Entität",
"logs.queue-group-active": "{active} aktiv (max. {max})", "logs.queue-group-active": "{active} aktiv (max. {max})",
"logs.queue-page-description": "Aktive und wartende Ausführungen, nach Aktion gruppiert. Einträge ohne Berechtigung werden ausgeblendet.", "logs.queue-group-active-unlimited": "{active} aktiv",
"logs.queue-group-limit": "Limit {max}, wartend {queued}",
"logs.queue-page-description": "Aktive und wartende Ausführungen, nach Aktionsgruppe gruppiert. Einträge ohne Berechtigung werden ausgeblendet.",
"logs.queue-position": "#{position}", "logs.queue-position": "#{position}",
"logs.queue-running": "Läuft", "logs.queue-running": "Läuft",
"logs.queue-title": "Ausführungswarteschlange", "logs.queue-title": "Ausführungswarteschlange",
@ -120,10 +123,13 @@
"logs.page-description": "This is a list of logs from actions that have been executed. Use the filter box for search terms and expressions.", "logs.page-description": "This is a list of logs from actions that have been executed. Use the filter box for search terms and expressions.",
"logs.queue": "Queue", "logs.queue": "Queue",
"logs.queue-action-details": "Action Details", "logs.queue-action-details": "Action Details",
"logs.queue-default-group": "Default",
"logs.queue-empty": "There are no active or waiting executions right now.", "logs.queue-empty": "There are no active or waiting executions right now.",
"logs.queue-entity": "Entity", "logs.queue-entity": "Entity",
"logs.queue-group-active": "{active} active (max {max})", "logs.queue-group-active": "{active} active (max {max})",
"logs.queue-page-description": "Active and waiting executions grouped by action. Entries you are not permitted to view are hidden.", "logs.queue-group-active-unlimited": "{active} active",
"logs.queue-group-limit": "limit {max}, queued {queued}",
"logs.queue-page-description": "Active and waiting executions grouped by action group. Entries you are not permitted to view are hidden.",
"logs.queue-position": "#{position}", "logs.queue-position": "#{position}",
"logs.queue-running": "Running", "logs.queue-running": "Running",
"logs.queue-title": "Execution Queue", "logs.queue-title": "Execution Queue",
@ -188,10 +194,13 @@
"logs.no-logs-to-display": "No hay registros para mostrar.", "logs.no-logs-to-display": "No hay registros para mostrar.",
"logs.page-description": "Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.", "logs.page-description": "Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.",
"logs.queue": "Cola", "logs.queue": "Cola",
"logs.queue-default-group": "Predeterminado",
"logs.queue-empty": "No hay ejecuciones activas o en espera en este momento.", "logs.queue-empty": "No hay ejecuciones activas o en espera en este momento.",
"logs.queue-entity": "Entidad", "logs.queue-entity": "Entidad",
"logs.queue-group-active": "{active} activas (máx. {max})", "logs.queue-group-active": "{active} activas (máx. {max})",
"logs.queue-page-description": "Ejecuciones activas y en espera agrupadas por acción. Las entradas que no puede ver se ocultan.", "logs.queue-group-active-unlimited": "{active} activas",
"logs.queue-group-limit": "límite {max}, en espera {queued}",
"logs.queue-page-description": "Ejecuciones activas y en espera agrupadas por grupo de acciones. Las entradas que no puede ver se ocultan.",
"logs.queue-position": "#{position}", "logs.queue-position": "#{position}",
"logs.queue-running": "En ejecución", "logs.queue-running": "En ejecución",
"logs.queue-title": "Cola de ejecución", "logs.queue-title": "Cola de ejecución",
@ -256,10 +265,13 @@
"logs.no-logs-to-display": "Non ci sono registri da mostrare.", "logs.no-logs-to-display": "Non ci sono registri da mostrare.",
"logs.page-description": "Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.", "logs.page-description": "Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.",
"logs.queue": "Coda", "logs.queue": "Coda",
"logs.queue-default-group": "Predefinito",
"logs.queue-empty": "Non ci sono esecuzioni attive o in attesa al momento.", "logs.queue-empty": "Non ci sono esecuzioni attive o in attesa al momento.",
"logs.queue-entity": "Entità", "logs.queue-entity": "Entità",
"logs.queue-group-active": "{active} attive (max {max})", "logs.queue-group-active": "{active} attive (max {max})",
"logs.queue-page-description": "Esecuzioni attive e in attesa raggruppate per azione. Le voci non autorizzate sono nascoste.", "logs.queue-group-active-unlimited": "{active} attive",
"logs.queue-group-limit": "limite {max}, in coda {queued}",
"logs.queue-page-description": "Esecuzioni attive e in attesa raggruppate per gruppo di azioni. Le voci non autorizzate sono nascoste.",
"logs.queue-position": "#{position}", "logs.queue-position": "#{position}",
"logs.queue-running": "In esecuzione", "logs.queue-running": "In esecuzione",
"logs.queue-title": "Coda di esecuzione", "logs.queue-title": "Coda di esecuzione",
@ -324,10 +336,13 @@
"logs.no-logs-to-display": "没有日志可显示。", "logs.no-logs-to-display": "没有日志可显示。",
"logs.page-description": "这是一个动作执行日志列表。您可以按动作标题过滤列表。", "logs.page-description": "这是一个动作执行日志列表。您可以按动作标题过滤列表。",
"logs.queue": "队列", "logs.queue": "队列",
"logs.queue-default-group": "默认",
"logs.queue-empty": "当前没有正在运行或等待中的执行。", "logs.queue-empty": "当前没有正在运行或等待中的执行。",
"logs.queue-entity": "实体", "logs.queue-entity": "实体",
"logs.queue-group-active": "{active} 个活动(上限 {max})", "logs.queue-group-active": "{active} 个活动(上限 {max})",
"logs.queue-page-description": "按动作分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。", "logs.queue-group-active-unlimited": "{active} 个活动",
"logs.queue-group-limit": "上限 {max},排队 {queued}",
"logs.queue-page-description": "按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。",
"logs.queue-position": "第 {position} 位", "logs.queue-position": "第 {position} 位",
"logs.queue-running": "运行中", "logs.queue-running": "运行中",
"logs.queue-title": "执行队列", "logs.queue-title": "执行队列",

View File

@ -33,7 +33,10 @@ translations:
logs.back-to-list: Zurück zur Liste logs.back-to-list: Zurück zur Liste
logs.queue: Warteschlange logs.queue: Warteschlange
logs.queue-title: Ausführungswarteschlange logs.queue-title: Ausführungswarteschlange
logs.queue-page-description: Aktive und wartende Ausführungen, nach Aktion gruppiert. Einträge ohne Berechtigung werden ausgeblendet. logs.queue-page-description: Aktive und wartende Ausführungen, nach Aktionsgruppe gruppiert. Einträge ohne Berechtigung werden ausgeblendet.
logs.queue-default-group: Standard
logs.queue-group-limit: "Limit {max}, wartend {queued}"
logs.queue-group-active-unlimited: "{active} aktiv"
logs.queue-empty: Derzeit gibt es keine aktiven oder wartenden Ausführungen. logs.queue-empty: Derzeit gibt es keine aktiven oder wartenden Ausführungen.
logs.queue-group-active: "{active} aktiv (max. {max})" logs.queue-group-active: "{active} aktiv (max. {max})"
logs.queue-waiting: Wartend logs.queue-waiting: Wartend

View File

@ -40,9 +40,12 @@ translations:
logs.back-to-list: Back to List logs.back-to-list: Back to List
logs.queue: Queue logs.queue: Queue
logs.queue-title: Execution Queue logs.queue-title: Execution Queue
logs.queue-page-description: Active and waiting executions grouped by action. Entries you are not permitted to view are hidden. logs.queue-page-description: Active and waiting executions grouped by action group. Entries you are not permitted to view are hidden.
logs.queue-empty: There are no active or waiting executions right now. logs.queue-empty: There are no active or waiting executions right now.
logs.queue-default-group: Default
logs.queue-group-limit: "limit {max}, queued {queued}"
logs.queue-group-active: "{active} active (max {max})" logs.queue-group-active: "{active} active (max {max})"
logs.queue-group-active-unlimited: "{active} active"
logs.queue-waiting: Waiting logs.queue-waiting: Waiting
logs.queue-running: Running logs.queue-running: Running
logs.queue-position: "#{position}" logs.queue-position: "#{position}"

View File

@ -33,7 +33,10 @@ translations:
logs.back-to-list: Volver a la Lista logs.back-to-list: Volver a la Lista
logs.queue: Cola logs.queue: Cola
logs.queue-title: Cola de ejecución logs.queue-title: Cola de ejecución
logs.queue-page-description: Ejecuciones activas y en espera agrupadas por acción. Las entradas que no puede ver se ocultan. logs.queue-page-description: Ejecuciones activas y en espera agrupadas por grupo de acciones. Las entradas que no puede ver se ocultan.
logs.queue-default-group: Predeterminado
logs.queue-group-limit: "límite {max}, en espera {queued}"
logs.queue-group-active-unlimited: "{active} activas"
logs.queue-empty: No hay ejecuciones activas o en espera en este momento. logs.queue-empty: No hay ejecuciones activas o en espera en este momento.
logs.queue-group-active: "{active} activas (máx. {max})" logs.queue-group-active: "{active} activas (máx. {max})"
logs.queue-waiting: En espera logs.queue-waiting: En espera

View File

@ -33,7 +33,10 @@ translations:
logs.back-to-list: Torna all'Elenco logs.back-to-list: Torna all'Elenco
logs.queue: Coda logs.queue: Coda
logs.queue-title: Coda di esecuzione logs.queue-title: Coda di esecuzione
logs.queue-page-description: Esecuzioni attive e in attesa raggruppate per azione. Le voci non autorizzate sono nascoste. logs.queue-page-description: Esecuzioni attive e in attesa raggruppate per gruppo di azioni. Le voci non autorizzate sono nascoste.
logs.queue-default-group: Predefinito
logs.queue-group-limit: "limite {max}, in coda {queued}"
logs.queue-group-active-unlimited: "{active} attive"
logs.queue-empty: Non ci sono esecuzioni attive o in attesa al momento. logs.queue-empty: Non ci sono esecuzioni attive o in attesa al momento.
logs.queue-group-active: "{active} attive (max {max})" logs.queue-group-active: "{active} attive (max {max})"
logs.queue-waiting: In attesa logs.queue-waiting: In attesa

View File

@ -42,7 +42,10 @@ translations:
logs.back-to-list: 返回列表 logs.back-to-list: 返回列表
logs.queue: 队列 logs.queue: 队列
logs.queue-title: 执行队列 logs.queue-title: 执行队列
logs.queue-page-description: 按动作分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。 logs.queue-page-description: 按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。
logs.queue-default-group: 默认
logs.queue-group-limit: "上限 {max},排队 {queued}"
logs.queue-group-active-unlimited: "{active} 个活动"
logs.queue-empty: 当前没有正在运行或等待中的执行。 logs.queue-empty: 当前没有正在运行或等待中的执行。
logs.queue-group-active: "{active} 个活动(上限 {max})" logs.queue-group-active: "{active} 个活动(上限 {max})"
logs.queue-waiting: 等待中 logs.queue-waiting: 等待中

View File

@ -192,7 +192,7 @@ message GetActionLogsResponse {
message GetExecutionQueueRequest {} message GetExecutionQueueRequest {}
message ExecutionQueueGroup { message ExecutionQueueAction {
string binding_id = 1; string binding_id = 1;
string action_title = 2; string action_title = 2;
string action_icon = 3; string action_icon = 3;
@ -202,6 +202,15 @@ message ExecutionQueueGroup {
repeated LogEntry entries = 7; repeated LogEntry entries = 7;
} }
message ExecutionQueueGroup {
string name = 1;
string icon = 2;
int32 max_concurrent = 3;
int32 active_count = 4;
repeated ExecutionQueueAction actions = 5;
int32 queued_count = 6;
}
message GetExecutionQueueResponse { message GetExecutionQueueResponse {
repeated ExecutionQueueGroup groups = 1; repeated ExecutionQueueGroup groups = 1;
int32 total_active = 2; int32 total_active = 2;

File diff suppressed because it is too large Load Diff

View File

@ -8,9 +8,17 @@ import (
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
"github.com/OliveTin/OliveTin/internal/auth" "github.com/OliveTin/OliveTin/internal/auth"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/executor" "github.com/OliveTin/OliveTin/internal/executor"
) )
const defaultActionGroupName = "default"
type executionQueueBucketKey struct {
groupName string
bindingID string
}
func (api *oliveTinAPI) GetExecutionQueue(ctx ctx.Context, req *connect.Request[apiv1.GetExecutionQueueRequest]) (*connect.Response[apiv1.GetExecutionQueueResponse], error) { func (api *oliveTinAPI) GetExecutionQueue(ctx ctx.Context, req *connect.Request[apiv1.GetExecutionQueueRequest]) (*connect.Response[apiv1.GetExecutionQueueResponse], error) {
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)
@ -28,23 +36,66 @@ func (api *oliveTinAPI) GetExecutionQueue(ctx ctx.Context, req *connect.Request[
} }
func buildExecutionQueueGroups(active []*executor.InternalLogEntry, user *authpublic.AuthenticatedUser, api *oliveTinAPI) []*apiv1.ExecutionQueueGroup { func buildExecutionQueueGroups(active []*executor.InternalLogEntry, user *authpublic.AuthenticatedUser, api *oliveTinAPI) []*apiv1.ExecutionQueueGroup {
grouped := make(map[string]*apiv1.ExecutionQueueGroup) actionBuckets := make(map[executionQueueBucketKey]*apiv1.ExecutionQueueAction)
for _, entry := range active { for _, entry := range active {
bindingID := entry.GetBindingId() addActiveEntryToActionBuckets(actionBuckets, entry, api.cfg, user, api)
group := grouped[bindingID]
if group == nil {
group = newExecutionQueueGroup(entry)
grouped[bindingID] = group
} }
group.Entries = append(group.Entries, api.internalLogEntryToPb(entry, user)) return buildExecutionQueueGroupsFromBuckets(actionBuckets, api.cfg)
}
func addActiveEntryToActionBuckets(
buckets map[executionQueueBucketKey]*apiv1.ExecutionQueueAction,
entry *executor.InternalLogEntry,
cfg *config.Config,
user *authpublic.AuthenticatedUser,
api *oliveTinAPI,
) {
for _, groupName := range enforcedActionGroupNames(entry, cfg) {
key := executionQueueBucketKey{
groupName: groupName,
bindingID: entry.GetBindingId(),
}
action := buckets[key]
if action == nil {
action = newExecutionQueueAction(entry)
buckets[key] = action
}
action.Entries = append(action.Entries, api.internalLogEntryToPb(entry, user))
}
}
func finalizeExecutionQueueGroup(group *apiv1.ExecutionQueueGroup) {
sortExecutionQueueActions(group.Actions)
group.ActiveCount = sumExecutionQueueActionEntries(group.Actions)
group.QueuedCount = countQueuedGroupEntries(group.Actions)
}
func buildExecutionQueueGroupsFromBuckets(
buckets map[executionQueueBucketKey]*apiv1.ExecutionQueueAction,
cfg *config.Config,
) []*apiv1.ExecutionQueueGroup {
grouped := make(map[string]*apiv1.ExecutionQueueGroup)
for key, action := range buckets {
sortQueueEntries(action.Entries)
action.ActiveCount = int32(len(action.Entries))
group := grouped[key.groupName]
if group == nil {
group = newExecutionQueueGroup(key.groupName, cfg)
grouped[key.groupName] = group
}
group.Actions = append(group.Actions, action)
} }
groups := make([]*apiv1.ExecutionQueueGroup, 0, len(grouped)) groups := make([]*apiv1.ExecutionQueueGroup, 0, len(grouped))
for _, group := range grouped { for _, group := range grouped {
sortQueueEntries(group.Entries) finalizeExecutionQueueGroup(group)
group.ActiveCount = int32(len(group.Entries))
groups = append(groups, group) groups = append(groups, group)
} }
@ -52,8 +103,56 @@ func buildExecutionQueueGroups(active []*executor.InternalLogEntry, user *authpu
return groups return groups
} }
func newExecutionQueueGroup(entry *executor.InternalLogEntry) *apiv1.ExecutionQueueGroup { func hasExecutionQueueBinding(entry *executor.InternalLogEntry, cfg *config.Config) bool {
group := &apiv1.ExecutionQueueGroup{ return entry != nil && entry.Binding != nil && entry.Binding.Action != nil && cfg != nil
}
func collectEnforcedActionGroupNames(groups []string, cfg *config.Config) []string {
names := make([]string, 0, len(groups))
for _, groupName := range groups {
if isEnforcedActionGroup(cfg, groupName) {
names = append(names, groupName)
}
}
return names
}
func enforcedActionGroupNames(entry *executor.InternalLogEntry, cfg *config.Config) []string {
if !hasExecutionQueueBinding(entry, cfg) {
return []string{defaultActionGroupName}
}
names := collectEnforcedActionGroupNames(entry.Binding.Action.Groups, cfg)
if len(names) == 0 {
return []string{defaultActionGroupName}
}
return names
}
func isEnforcedActionGroup(cfg *config.Config, groupName string) bool {
group, found := cfg.ActionGroups[groupName]
return found && group != nil && group.MaxConcurrent >= 1
}
func newExecutionQueueGroup(name string, cfg *config.Config) *apiv1.ExecutionQueueGroup {
group := &apiv1.ExecutionQueueGroup{Name: name}
if name == defaultActionGroupName {
return group
}
actionGroup, found := cfg.ActionGroups[name]
if !found || actionGroup == nil {
return group
}
group.Icon = actionGroup.Icon
group.MaxConcurrent = int32(actionGroup.MaxConcurrent)
return group
}
func newExecutionQueueAction(entry *executor.InternalLogEntry) *apiv1.ExecutionQueueAction {
action := &apiv1.ExecutionQueueAction{
BindingId: entry.GetBindingId(), BindingId: entry.GetBindingId(),
ActionTitle: entry.ActionTitle, ActionTitle: entry.ActionTitle,
ActionIcon: entry.ActionIcon, ActionIcon: entry.ActionIcon,
@ -61,10 +160,34 @@ func newExecutionQueueGroup(entry *executor.InternalLogEntry) *apiv1.ExecutionQu
} }
if entry.Binding != nil && entry.Binding.Action != nil { if entry.Binding != nil && entry.Binding.Action != nil {
group.MaxConcurrent = int32(entry.Binding.Action.MaxConcurrent) action.MaxConcurrent = int32(entry.Binding.Action.MaxConcurrent)
} }
return group return action
}
func sumExecutionQueueActionEntries(actions []*apiv1.ExecutionQueueAction) int32 {
var total int32
for _, action := range actions {
total += int32(len(action.Entries))
}
return total
}
func countQueuedGroupEntries(actions []*apiv1.ExecutionQueueAction) int32 {
var total int32
for _, action := range actions {
for _, entry := range action.Entries {
if entry.Queued {
total++
}
}
}
return total
} }
func sortQueueEntries(entries []*apiv1.LogEntry) { func sortQueueEntries(entries []*apiv1.LogEntry) {
@ -73,13 +196,31 @@ func sortQueueEntries(entries []*apiv1.LogEntry) {
}) })
} }
func sortExecutionQueueGroups(groups []*apiv1.ExecutionQueueGroup) { func sortExecutionQueueActions(actions []*apiv1.ExecutionQueueAction) {
sort.Slice(groups, func(i, j int) bool { sort.Slice(actions, func(i, j int) bool {
left := groups[i].ActionTitle left := actions[i].ActionTitle
right := groups[j].ActionTitle right := actions[j].ActionTitle
if left == right { if left == right {
return groups[i].EntityPrefix < groups[j].EntityPrefix return actions[i].EntityPrefix < actions[j].EntityPrefix
} }
return left < right
})
}
func sortExecutionQueueGroups(groups []*apiv1.ExecutionQueueGroup) {
sort.Slice(groups, func(i, j int) bool {
left := groups[i].Name
right := groups[j].Name
if left == defaultActionGroupName {
return false
}
if right == defaultActionGroupName {
return true
}
return left < right return left < right
}) })
} }

View File

@ -14,10 +14,13 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestGetExecutionQueueGroupsByBinding(t *testing.T) { func TestGetExecutionQueueGroupsByActionGroup(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.ActionGroups = map[string]*config.ActionGroup{
"deploy": {MaxConcurrent: 2, Icon: "backup"},
}
cfg.Actions = []*config.Action{ cfg.Actions = []*config.Action{
{Title: "backup", Shell: "sleep 1", MaxConcurrent: 1}, {Title: "backup", Shell: "sleep 1", MaxConcurrent: 1, Groups: []string{"deploy"}},
{Title: "ping", Shell: "echo ping"}, {Title: "ping", Shell: "echo ping"},
} }
cfg.Sanitize() cfg.Sanitize()
@ -32,6 +35,7 @@ func TestGetExecutionQueueGroupsByBinding(t *testing.T) {
backupRunning := newAPIQueueLogEntry(backupBinding, true, false) backupRunning := newAPIQueueLogEntry(backupBinding, true, false)
backupWaiting := newAPIQueueLogEntry(backupBinding, false, false) backupWaiting := newAPIQueueLogEntry(backupBinding, false, false)
backupWaiting.Queued = true
pingRunning := newAPIQueueLogEntry(pingBinding, true, false) pingRunning := newAPIQueueLogEntry(pingBinding, true, false)
ex.SetLog(backupRunning.ExecutionTrackingID, backupRunning) ex.SetLog(backupRunning.ExecutionTrackingID, backupRunning)
@ -47,20 +51,32 @@ func TestGetExecutionQueueGroupsByBinding(t *testing.T) {
assert.Equal(t, int32(3), resp.Msg.TotalActive) assert.Equal(t, int32(3), resp.Msg.TotalActive)
require.Len(t, resp.Msg.Groups, 2) require.Len(t, resp.Msg.Groups, 2)
var backupGroup *apiv1.ExecutionQueueGroup deployGroup := findExecutionQueueGroup(resp.Msg.Groups, "deploy")
for _, group := range resp.Msg.Groups { defaultGroup := findExecutionQueueGroup(resp.Msg.Groups, defaultActionGroupName)
if group.BindingId == backupBinding.ID { require.NotNil(t, deployGroup)
backupGroup = group require.NotNil(t, defaultGroup)
assert.Equal(t, int32(2), deployGroup.MaxConcurrent)
assert.Equal(t, "&#128190;", deployGroup.Icon)
assert.Equal(t, int32(2), deployGroup.ActiveCount)
assert.Equal(t, int32(1), deployGroup.QueuedCount)
require.Len(t, deployGroup.Actions, 1)
assert.Equal(t, "backup", deployGroup.Actions[0].ActionTitle)
require.Len(t, deployGroup.Actions[0].Entries, 2)
require.Len(t, defaultGroup.Actions, 1)
assert.Equal(t, "ping", defaultGroup.Actions[0].ActionTitle)
assert.Equal(t, int32(1), defaultGroup.Actions[0].ActiveCount)
}
func findExecutionQueueGroup(groups []*apiv1.ExecutionQueueGroup, name string) *apiv1.ExecutionQueueGroup {
for _, group := range groups {
if group.Name == name {
return group
} }
} }
require.NotNil(t, backupGroup) return nil
assert.Equal(t, "backup", backupGroup.ActionTitle)
assert.Equal(t, int32(1), backupGroup.MaxConcurrent)
assert.Equal(t, int32(2), backupGroup.ActiveCount)
require.Len(t, backupGroup.Entries, 2)
assert.False(t, backupGroup.Entries[1].ExecutionStarted)
assert.True(t, backupGroup.Entries[0].ExecutionStarted)
} }
func newAPIQueueLogEntry(binding *executor.ActionBinding, started bool, finished bool) *executor.InternalLogEntry { func newAPIQueueLogEntry(binding *executor.ActionBinding, started bool, finished bool) *executor.InternalLogEntry {

View File

@ -28,6 +28,7 @@ func (cfg *Config) Sanitize() {
cfg.sanitizeDashboardsForInlineActions() cfg.sanitizeDashboardsForInlineActions()
cfg.sanitizeActionGroups()
cfg.sanitizeActionGroupReferences() cfg.sanitizeActionGroupReferences()
if err := cfg.validateReservedActionArgumentNames(); err != nil { if err := cfg.validateReservedActionArgumentNames(); err != nil {
@ -218,6 +219,16 @@ func appendUniqueString(out []string, seen map[string]struct{}, value string) []
return append(out, value) return append(out, value)
} }
func (cfg *Config) sanitizeActionGroups() {
for _, group := range cfg.ActionGroups {
if group == nil {
continue
}
group.Icon = lookupHTMLIcon(group.Icon, cfg.DefaultIconForActions)
}
}
func (cfg *Config) sanitizeActionGroupReferences() { func (cfg *Config) sanitizeActionGroupReferences() {
for _, action := range cfg.Actions { for _, action := range cfg.Actions {
for _, groupName := range action.Groups { for _, groupName := range action.Groups {

View File

@ -185,6 +185,17 @@ func TestSanitizeActionGroupsDedupesGroupNames(t *testing.T) {
assert.Equal(t, []string{"unity"}, action.Groups) assert.Equal(t, []string{"unity"}, action.Groups)
} }
func TestSanitizeActionGroupsResolvesIcons(t *testing.T) {
c := DefaultConfig()
c.ActionGroups = map[string]*ActionGroup{
"backup-jobs": {MaxConcurrent: 1, Icon: "backup"},
}
c.Sanitize()
assert.Equal(t, "&#128190;", c.ActionGroups["backup-jobs"].Icon)
}
func TestValidateReservedActionArgumentNamesAllowsNonReserved(t *testing.T) { func TestValidateReservedActionArgumentNamesAllowsNonReserved(t *testing.T) {
c := DefaultConfig() c := DefaultConfig()
c.Actions = append(c.Actions, &Action{ c.Actions = append(c.Actions, &Action{