Parcourir la source

fix: action group display fixes

jamesread il y a 2 semaines
Parent
commit
03a9a05e38

+ 43 - 2
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -834,9 +834,9 @@ export declare type GetExecutionQueueRequest = Message<"olivetin.api.v1.GetExecu
 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;
    */
@@ -873,6 +873,47 @@ export declare type ExecutionQueueGroup = Message<"olivetin.api.v1.ExecutionQueu
   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.
  * Use `create(ExecutionQueueGroupSchema)` to create a new message.

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 17 - 11
frontend/resources/vue/utils/executionLogEvents.js

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

+ 191 - 142
frontend/resources/vue/views/LogsQueueView.vue

@@ -18,74 +18,73 @@
   </Section>
 
   <section
-    v-for="group in groups"
-    :key="group.bindingId"
-    class="with-header-and-content queue-group-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-group-heading">
-        <ActionIconGlyph class="queue-group-icon" :glyph="group.actionIcon" />
-        <div class="queue-group-title">
-          <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 role="toolbar" class="queue-group-toolbar">
-        <router-link
-          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">
-            <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>
+      <div class="fg1 queue-action-group-heading">
+        <ActionIconGlyph class="icon" :glyph="actionGroup.icon" />
+        <h2>{{ displayActionGroupName(actionGroup.name) }}</h2>
       </div>
+      <span
+        v-if="actionGroup.maxConcurrent > 0"
+        class="queue-action-group-limit annotation"
+      >
+        {{ t('logs.queue-group-limit', { max: actionGroup.maxConcurrent, queued: actionGroup.queuedCount }) }}
+      </span>
     </div>
 
     <div class="section-content">
       <table class="logs-table row-hover">
-      <thead>
-        <tr>
-          <th>{{ t('logs.timestamp') }}</th>
-          <th>{{ t('logs.metadata') }}</th>
-          <th>{{ t('logs.status') }}</th>
-        </tr>
-      </thead>
-      <tbody>
-        <tr v-for="(entry, index) in group.entries" :key="entry.executionTrackingId" class="log-row">
-          <td class="timestamp">{{ formatTimestamp(entry.datetimeStarted) }}</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>
-            <span class="annotation">
-              <span class="annotation-key">ID:</span>
-              <router-link :to="`/logs/${entry.executionTrackingId}`">
-                {{ entry.executionTrackingId }}
-              </router-link>
-            </span>
-          </td>
-          <td class="exit-code">
-            <span v-if="!entry.executionFinished" class="queue-position">{{ t('logs.queue-position', { position: index + 1 }) }}</span>
-            <ActionStatusDisplay :logEntry="entry" />
-          </td>
-        </tr>
-      </tbody>
-    </table>
+        <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 :logEntry="entry" link-queued-status />
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
     </div>
   </section>
 </template>
@@ -95,20 +94,33 @@ 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 { 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 entry of group.entries || []) {
-      if (entry.executionFinished) {
-        completed.push(cloneLogEntry(entry))
+    for (const action of group.actions || []) {
+      for (const entry of action.entries || []) {
+        if (entry.executionFinished) {
+          completed.push(cloneLogEntry(entry))
+        }
       }
     }
   }
@@ -126,53 +138,108 @@ function sortGroupEntries (entries) {
 
 function sortGroups (groupList) {
   groupList.sort((left, right) => {
-    const byTitle = (left.actionTitle || '').localeCompare(right.actionTitle || '')
-    if (byTitle !== 0) {
-      return byTitle
+    if (left.name === defaultActionGroupName) {
+      return 1
+    }
+    if (right.name === defaultActionGroupName) {
+      return -1
     }
-    return (left.entityPrefix || '').localeCompare(right.entityPrefix || '')
+    return (left.name || '').localeCompare(right.name || '')
   })
 }
 
-function mergeCompletedEntries (apiGroups, completedEntries) {
-  const merged = (apiGroups || []).map(group => ({
+function sumActionEntries (actions) {
+  return (actions || []).reduce((total, action) => total + (action.entries || []).length, 0)
+}
+
+function cloneActionGroup (group) {
+  return {
     ...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) {
     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) {
       continue
     }
 
-    let group = merged.find(item => item.bindingId === entry.bindingId)
-    if (!group) {
-      group = {
-        bindingId: entry.bindingId,
-        actionTitle: entry.actionTitle,
-        actionIcon: entry.actionIcon,
-        entityPrefix: '',
-        maxConcurrent: 0,
-        activeCount: 0,
-        entries: []
-      }
-      merged.push(group)
+    const matches = findActionInGroups(merged, entry.bindingId)
+    if (matches.length === 0) {
+      continue
     }
 
-    group.entries.push(entry)
+    for (const { action } of matches) {
+      action.entries.push(entry)
+    }
   }
 
   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)
 
   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) {
@@ -182,16 +249,13 @@ function applyQueueEntryUpdate (logEntry, afterUpdate) {
   if (afterUpdate) {
     afterUpdate(result)
   }
-  sortGroupEntries(result.group.entries)
-  return true
-}
 
-function adjustActiveCountOnStart (group, previous, logEntry) {
-  const wasActive = !previous.executionFinished
-  const isActive = !logEntry.executionFinished
-  if (!wasActive && isActive) {
-    group.activeCount++
-  }
+  forEachActionWithEntry(groups.value, logEntry.executionTrackingId, (group, action) => {
+    sortGroupEntries(action.entries)
+    refreshGroupCounts(group)
+  })
+
+  return true
 }
 
 function insertActiveQueueEntry (logEntry) {
@@ -200,23 +264,22 @@ function insertActiveQueueEntry (logEntry) {
     return
   }
 
-  let group = groups.value.find(item => item.bindingId === logEntry.bindingId)
-  if (!group) {
-    group = {
-      bindingId: logEntry.bindingId,
-      actionTitle: logEntry.actionTitle || '',
-      actionIcon: logEntry.actionIcon || '',
-      entityPrefix: logEntry.entityPrefix || '',
-      maxConcurrent: 0,
-      activeCount: 0,
-      entries: []
-    }
-    groups.value.push(group)
+  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)
   }
 
-  group.entries.push(cloneLogEntry(logEntry))
-  adjustActiveCountOnStart(group, { executionFinished: true }, logEntry)
-  sortGroupEntries(group.entries)
+  for (const group of touchedGroups) {
+    refreshGroupCounts(group)
+  }
   sortGroups(groups.value)
 }
 
@@ -226,9 +289,7 @@ function onExecutionStarted (evt) {
     return
   }
 
-  if (!applyQueueEntryUpdate(logEntry, ({ group, previous }) => {
-    adjustActiveCountOnStart(group, previous, logEntry)
-  })) {
+  if (!applyQueueEntryUpdate(logEntry)) {
     insertActiveQueueEntry(logEntry)
   }
 }
@@ -239,12 +300,7 @@ function onExecutionFinished (evt) {
     return
   }
 
-  applyQueueEntryUpdate(logEntry, ({ group, previous }) => {
-    const wasActive = !previous.executionFinished
-    if (wasActive && logEntry.executionFinished && group.activeCount > 0) {
-      group.activeCount--
-    }
-  })
+  applyQueueEntryUpdate(logEntry)
 }
 
 function formatTimestamp (timestamp) {
@@ -285,50 +341,43 @@ onUnmounted(() => {
 </script>
 
 <style scoped>
-.queue-group-heading {
+.queue-action-group-heading {
   display: flex;
   align-items: center;
-  gap: 0.75rem;
+  gap: 0.5rem;
   min-width: 0;
 }
 
-.queue-group-title h2 {
+.queue-action-group-heading h2 {
   margin: 0;
 }
 
-.queue-group-toolbar {
-  display: inline-flex;
-  flex-wrap: wrap;
-  align-items: center;
-  gap: 0.5rem;
-}
-
-.queue-group-limit {
+.queue-action-group-limit {
   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 {
   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;

+ 20 - 5
lang/combined_output.json

@@ -44,10 +44,13 @@
             "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.queue": "Warteschlange",
+            "logs.queue-default-group": "Standard",
             "logs.queue-empty": "Derzeit gibt es keine aktiven oder wartenden Ausführungen.",
             "logs.queue-entity": "Entität",
             "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-running": "Läuft",
             "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.queue": "Queue",
             "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-entity": "Entity",
             "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-running": "Running",
             "logs.queue-title": "Execution Queue",
@@ -188,10 +194,13 @@
             "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.queue": "Cola",
+            "logs.queue-default-group": "Predeterminado",
             "logs.queue-empty": "No hay ejecuciones activas o en espera en este momento.",
             "logs.queue-entity": "Entidad",
             "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-running": "En 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.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-default-group": "Predefinito",
             "logs.queue-empty": "Non ci sono esecuzioni attive o in attesa al momento.",
             "logs.queue-entity": "Entità",
             "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-running": "In esecuzione",
             "logs.queue-title": "Coda di esecuzione",
@@ -324,10 +336,13 @@
             "logs.no-logs-to-display": "没有日志可显示。",
             "logs.page-description": "这是一个动作执行日志列表。您可以按动作标题过滤列表。",
             "logs.queue": "队列",
+            "logs.queue-default-group": "默认",
             "logs.queue-empty": "当前没有正在运行或等待中的执行。",
             "logs.queue-entity": "实体",
             "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-running": "运行中",
             "logs.queue-title": "执行队列",

+ 4 - 1
lang/de-DE.yaml

@@ -33,7 +33,10 @@ translations:
   logs.back-to-list: Zurück zur Liste
   logs.queue: Warteschlange
   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-group-active: "{active} aktiv (max. {max})"
   logs.queue-waiting: Wartend

+ 4 - 1
lang/en.yaml

@@ -40,9 +40,12 @@ translations:
   logs.back-to-list: Back to List
   logs.queue: 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-default-group: Default
+  logs.queue-group-limit: "limit {max}, queued {queued}"
   logs.queue-group-active: "{active} active (max {max})"
+  logs.queue-group-active-unlimited: "{active} active"
   logs.queue-waiting: Waiting
   logs.queue-running: Running
   logs.queue-position: "#{position}"

+ 4 - 1
lang/es-ES.yaml

@@ -33,7 +33,10 @@ translations:
   logs.back-to-list: Volver a la Lista
   logs.queue: Cola
   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-group-active: "{active} activas (máx. {max})"
   logs.queue-waiting: En espera

+ 4 - 1
lang/it-IT.yaml

@@ -33,7 +33,10 @@ translations:
   logs.back-to-list: Torna all'Elenco
   logs.queue: Coda
   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-group-active: "{active} attive (max {max})"
   logs.queue-waiting: In attesa

+ 4 - 1
lang/zh-Hans-CN.yaml

@@ -42,7 +42,10 @@ translations:
   logs.back-to-list: 返回列表
   logs.queue: 队列
   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-group-active: "{active} 个活动(上限 {max})"
   logs.queue-waiting: 等待中

+ 10 - 1
proto/olivetin/api/v1/olivetin.proto

@@ -192,7 +192,7 @@ message GetActionLogsResponse {
 
 message GetExecutionQueueRequest {}
 
-message ExecutionQueueGroup {
+message ExecutionQueueAction {
 	string binding_id = 1;
 	string action_title = 2;
 	string action_icon = 3;
@@ -202,6 +202,15 @@ message ExecutionQueueGroup {
 	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 {
 	repeated ExecutionQueueGroup groups = 1;
 	int32 total_active = 2;

Fichier diff supprimé car celui-ci est trop grand
+ 190 - 106
service/gen/olivetin/api/v1/olivetin.pb.go


+ 157 - 16
service/internal/api/api_queue.go

@@ -8,9 +8,17 @@ import (
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	"github.com/OliveTin/OliveTin/internal/auth"
 	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
 	"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) {
 	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 {
-	grouped := make(map[string]*apiv1.ExecutionQueueGroup)
+	actionBuckets := make(map[executionQueueBucketKey]*apiv1.ExecutionQueueAction)
 
 	for _, entry := range active {
-		bindingID := entry.GetBindingId()
-		group := grouped[bindingID]
+		addActiveEntryToActionBuckets(actionBuckets, entry, api.cfg, user, api)
+	}
+
+	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(entry)
-			grouped[bindingID] = group
+			group = newExecutionQueueGroup(key.groupName, cfg)
+			grouped[key.groupName] = group
 		}
 
-		group.Entries = append(group.Entries, api.internalLogEntryToPb(entry, user))
+		group.Actions = append(group.Actions, action)
 	}
 
 	groups := make([]*apiv1.ExecutionQueueGroup, 0, len(grouped))
 	for _, group := range grouped {
-		sortQueueEntries(group.Entries)
-		group.ActiveCount = int32(len(group.Entries))
+		finalizeExecutionQueueGroup(group)
 		groups = append(groups, group)
 	}
 
@@ -52,8 +103,56 @@ func buildExecutionQueueGroups(active []*executor.InternalLogEntry, user *authpu
 	return groups
 }
 
-func newExecutionQueueGroup(entry *executor.InternalLogEntry) *apiv1.ExecutionQueueGroup {
-	group := &apiv1.ExecutionQueueGroup{
+func hasExecutionQueueBinding(entry *executor.InternalLogEntry, cfg *config.Config) bool {
+	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(),
 		ActionTitle:  entry.ActionTitle,
 		ActionIcon:   entry.ActionIcon,
@@ -61,10 +160,34 @@ func newExecutionQueueGroup(entry *executor.InternalLogEntry) *apiv1.ExecutionQu
 	}
 
 	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) {
@@ -73,13 +196,31 @@ func sortQueueEntries(entries []*apiv1.LogEntry) {
 	})
 }
 
+func sortExecutionQueueActions(actions []*apiv1.ExecutionQueueAction) {
+	sort.Slice(actions, func(i, j int) bool {
+		left := actions[i].ActionTitle
+		right := actions[j].ActionTitle
+		if left == right {
+			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].ActionTitle
-		right := groups[j].ActionTitle
-		if left == right {
-			return groups[i].EntityPrefix < groups[j].EntityPrefix
+		left := groups[i].Name
+		right := groups[j].Name
+
+		if left == defaultActionGroupName {
+			return false
+		}
+
+		if right == defaultActionGroupName {
+			return true
 		}
+
 		return left < right
 	})
 }

+ 29 - 13
service/internal/api/api_queue_test.go

@@ -14,10 +14,13 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestGetExecutionQueueGroupsByBinding(t *testing.T) {
+func TestGetExecutionQueueGroupsByActionGroup(t *testing.T) {
 	cfg := config.DefaultConfig()
+	cfg.ActionGroups = map[string]*config.ActionGroup{
+		"deploy": {MaxConcurrent: 2, Icon: "backup"},
+	}
 	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"},
 	}
 	cfg.Sanitize()
@@ -32,6 +35,7 @@ func TestGetExecutionQueueGroupsByBinding(t *testing.T) {
 
 	backupRunning := newAPIQueueLogEntry(backupBinding, true, false)
 	backupWaiting := newAPIQueueLogEntry(backupBinding, false, false)
+	backupWaiting.Queued = true
 	pingRunning := newAPIQueueLogEntry(pingBinding, true, false)
 
 	ex.SetLog(backupRunning.ExecutionTrackingID, backupRunning)
@@ -47,20 +51,32 @@ func TestGetExecutionQueueGroupsByBinding(t *testing.T) {
 	assert.Equal(t, int32(3), resp.Msg.TotalActive)
 	require.Len(t, resp.Msg.Groups, 2)
 
-	var backupGroup *apiv1.ExecutionQueueGroup
-	for _, group := range resp.Msg.Groups {
-		if group.BindingId == backupBinding.ID {
-			backupGroup = group
+	deployGroup := findExecutionQueueGroup(resp.Msg.Groups, "deploy")
+	defaultGroup := findExecutionQueueGroup(resp.Msg.Groups, defaultActionGroupName)
+	require.NotNil(t, deployGroup)
+	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)
-	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)
+	return nil
 }
 
 func newAPIQueueLogEntry(binding *executor.ActionBinding, started bool, finished bool) *executor.InternalLogEntry {

+ 11 - 0
service/internal/config/sanitize.go

@@ -28,6 +28,7 @@ func (cfg *Config) Sanitize() {
 
 	cfg.sanitizeDashboardsForInlineActions()
 
+	cfg.sanitizeActionGroups()
 	cfg.sanitizeActionGroupReferences()
 
 	if err := cfg.validateReservedActionArgumentNames(); err != nil {
@@ -218,6 +219,16 @@ func appendUniqueString(out []string, seen map[string]struct{}, value string) []
 	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() {
 	for _, action := range cfg.Actions {
 		for _, groupName := range action.Groups {

+ 11 - 0
service/internal/config/sanitize_test.go

@@ -185,6 +185,17 @@ func TestSanitizeActionGroupsDedupesGroupNames(t *testing.T) {
 	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) {
 	c := DefaultConfig()
 	c.Actions = append(c.Actions, &Action{

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff