fix: action group display fixes
This commit is contained in:
parent
de68ba7433
commit
03a9a05e38
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,8 +36,11 @@ export function updateLogEntryInGroups (groups, logEntry) {
|
|||
return null
|
||||
}
|
||||
|
||||
let firstMatch = null
|
||||
|
||||
for (const group of groups) {
|
||||
const entries = group.entries || []
|
||||
for (const action of group.actions || []) {
|
||||
const entries = action.entries || []
|
||||
const index = entries.findIndex(
|
||||
item => item.executionTrackingId === entry.executionTrackingId
|
||||
)
|
||||
|
|
@ -47,8 +50,11 @@ export function updateLogEntryInGroups (groups, logEntry) {
|
|||
|
||||
const previous = entries[index]
|
||||
entries[index] = entry
|
||||
return { group, index, previous }
|
||||
if (!firstMatch) {
|
||||
firstMatch = { group, action, index, previous }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return firstMatch
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,52 +18,54 @@
|
|||
</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 class="fg1 queue-action-group-heading">
|
||||
<ActionIconGlyph class="icon" :glyph="actionGroup.icon" />
|
||||
<h2>{{ displayActionGroupName(actionGroup.name) }}</h2>
|
||||
</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')"
|
||||
<span
|
||||
v-if="actionGroup.maxConcurrent > 0"
|
||||
class="queue-action-group-limit annotation"
|
||||
>
|
||||
<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 }) }}
|
||||
{{ t('logs.queue-group-limit', { max: actionGroup.maxConcurrent, queued: actionGroup.queuedCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -72,18 +74,15 @@
|
|||
<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" />
|
||||
<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>
|
||||
|
|
@ -95,23 +94,36 @@ 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 || []) {
|
||||
for (const action of group.actions || []) {
|
||||
for (const entry of action.entries || []) {
|
||||
if (entry.executionFinished) {
|
||||
completed.push(cloneLogEntry(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
return (left.entityPrefix || '').localeCompare(right.entityPrefix || '')
|
||||
if (right.name === defaultActionGroupName) {
|
||||
return -1
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
group.entries.push(cloneLogEntry(logEntry))
|
||||
adjustActiveCountOnStart(group, { executionFinished: true }, logEntry)
|
||||
sortGroupEntries(group.entries)
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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": "执行队列",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: 等待中
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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]
|
||||
if group == nil {
|
||||
group = newExecutionQueueGroup(entry)
|
||||
grouped[bindingID] = group
|
||||
addActiveEntryToActionBuckets(actionBuckets, entry, api.cfg, user, api)
|
||||
}
|
||||
|
||||
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))
|
||||
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 sortExecutionQueueGroups(groups []*apiv1.ExecutionQueueGroup) {
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
left := groups[i].ActionTitle
|
||||
right := groups[j].ActionTitle
|
||||
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 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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "💾", 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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, "💾", c.ActionGroups["backup-jobs"].Icon)
|
||||
}
|
||||
|
||||
func TestValidateReservedActionArgumentNamesAllowsNonReserved(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
c.Actions = append(c.Actions, &Action{
|
||||
|
|
|
|||
Loading…
Reference in New Issue