feat: popupOnStart history, and logs in reverse order on action details view

This commit is contained in:
jamesread 2026-05-19 23:19:42 +01:00
parent be1db222bb
commit fe68264c2b
8 changed files with 130 additions and 45 deletions

View File

@ -68,4 +68,16 @@ actions:
image::../executionButtons.png[]
== Action execution history
The `history` option opens the action details page for that binding when the execution starts, so you can see past runs and status for the same action.
[source,yaml]
.`config.yaml`
----
actions:
- title: Long-running job
popupOnStart: history
----

View File

@ -44,6 +44,7 @@ image::defaultUiHideNav.png[]
When enabled (the default), each action button can show a small icon indicating what happens when the action is started:
* **Popup dialog** — the action opens a popup (e.g. `popupOnStart: execution-dialog`)
* **Action history** — the action opens the action details page (e.g. `popupOnStart: history`)
* **Argument form** — the action opens an argument form on start
* **Run in background** — the action runs without opening a dialog

View File

@ -10,6 +10,9 @@
<div v-if="navigateOnStart == 'arg'" class="navigate-on-start" title="Opens an argument form on start">
<HugeiconsIcon :icon="TypeCursorIcon" />
</div>
<div v-if="navigateOnStart == 'hist'" class="navigate-on-start" title="Opens action execution history on start">
<HugeiconsIcon :icon="WorkHistoryIcon" />
</div>
<div v-if="navigateOnStart == ''" class="navigate-on-start" title="Run in the background">
<HugeiconsIcon :icon="WorkoutRunIcon" />
</div>
@ -28,7 +31,7 @@ import { buttonResults } from './stores/buttonResults'
import { rateLimits } from './stores/rateLimits'
import { useRouter } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue'
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
import { ref, watch, onMounted, onUnmounted, inject, computed } from 'vue'
@ -108,6 +111,8 @@ function constructFromJson(json) {
if (popupOnStart.value.includes('execution-dialog')) {
navigateOnStart.value = 'pop'
} else if (popupOnStart.value === 'history') {
navigateOnStart.value = 'hist'
} else if (props.actionData.arguments.length > 0) {
navigateOnStart.value = 'arg'
}
@ -244,6 +249,8 @@ function onLogEntryChanged(logEntry) {
function onExecutionStarted(logEntry) {
if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
router.push(`/logs/${logEntry.executionTrackingId}`)
} else if (popupOnStart.value === 'history') {
router.push(`/action/${bindingId.value}`)
}
isDisabled.value = true

View File

@ -17,7 +17,7 @@
<dt>Timeout</dt>
<dd>{{ action.timeout }} seconds</dd>
</dl>
<p v-if="action" class = "fg1">
<p class = "fg1">
Execution history for this action. You can filter by execution tracking ID.
</p>
</div>
@ -47,6 +47,7 @@
<thead>
<tr>
<th>Timestamp</th>
<th>Duration</th>
<th>Execution ID</th>
<th>Metadata</th>
<th>Status</th>
@ -55,6 +56,7 @@
<tbody>
<tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
<td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
<td class="duration">{{ formatExecutionDuration(log) }}</td>
<td>
<router-link :to="`/logs/${log.executionTrackingId}`">
{{ log.executionTrackingId }}
@ -70,9 +72,7 @@
</span>
</td>
<td class="exit-code">
<span :class="getStatusClass(log) + ' annotation'">
{{ getStatusText(log) }}
</span>
<ActionStatusDisplay :logEntry="log" />
</td>
</tr>
</tbody>
@ -90,10 +90,11 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Pagination from 'picocrank/vue/components/Pagination.vue'
import Section from 'picocrank/vue/components/Section.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
const route = useRoute()
const router = useRouter()
@ -105,6 +106,8 @@ const pageSize = ref(10)
const currentPage = ref(1)
const loading = ref(false)
const totalCount = ref(0)
const durationClock = ref(Date.now())
let durationTicker = null
const filteredLogs = computed(() => {
if (!searchText.value) {
@ -137,6 +140,7 @@ async function fetchActionLogs() {
pageSize.value = serverPageSize
}
totalCount.value = Number(response.totalCount) || 0
syncDurationTicker()
} catch (err) {
console.error('Failed to fetch action logs:', err)
window.showBigError('fetch-action-logs', 'getting action logs', err, false)
@ -168,6 +172,7 @@ function resetState() {
currentPage.value = 1
searchText.value = ''
loading.value = true
syncDurationTicker()
}
function clearSearch() {
@ -184,20 +189,78 @@ function formatTimestamp(timestamp) {
}
}
function getStatusClass(log) {
if (log.timedOut) return 'status-timeout'
if (log.blocked) return 'status-blocked'
if (log.exitCode !== 0) return 'status-error'
return 'status-success'
function plural(n, singular, pluralForm) {
return n === 1 ? `1 ${singular}` : `${n} ${pluralForm}`
}
function getStatusText(log) {
if (log.timedOut) return 'Timed out'
if (log.blocked) return 'Blocked'
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
return 'Completed'
function formatDurationSimple(ms) {
if (!Number.isFinite(ms) || ms < 0) {
return '—'
}
const totalSec = Math.round(ms / 1000)
if (totalSec === 0) {
return '0 seconds'
}
const days = Math.floor(totalSec / 86400)
const hours = Math.floor((totalSec % 86400) / 3600)
const minutes = Math.floor((totalSec % 3600) / 60)
const seconds = totalSec % 60
const parts = []
if (days > 0) parts.push(plural(days, 'day', 'days'))
if (hours > 0) parts.push(plural(hours, 'hour', 'hours'))
if (minutes > 0) parts.push(plural(minutes, 'minute', 'minutes'))
if (seconds > 0) parts.push(plural(seconds, 'second', 'seconds'))
return parts.join(' ')
}
function formatExecutionDuration(log) {
// Reading durationClock keeps this column reactive while executions are in progress.
const clock = durationClock.value
if (!log?.datetimeStarted) {
return '—'
}
const started = new Date(log.datetimeStarted)
if (Number.isNaN(started.getTime())) {
return '—'
}
let endMs
if (log.executionFinished) {
const finished = new Date(log.datetimeFinished)
if (Number.isNaN(finished.getTime())) {
return '—'
}
endMs = finished.getTime()
} else {
endMs = clock
}
return formatDurationSimple(endMs - started.getTime())
}
function syncDurationTicker() {
if (durationTicker != null) {
clearInterval(durationTicker)
durationTicker = null
}
const hasRunning = logs.value.some(l => !l.executionFinished)
if (!hasRunning) {
return
}
durationTicker = window.setInterval(() => {
durationClock.value = Date.now()
}, 1000)
}
onUnmounted(() => {
if (durationTicker != null) {
clearInterval(durationTicker)
durationTicker = null
}
})
function handlePageChange(page) {
currentPage.value = page
fetchActionLogs()
@ -246,16 +309,6 @@ watch(
</script>
<style scoped>
.action-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-header h2 {
margin: 0;
}
.icon {
font-size: 1.5rem;
}
@ -287,6 +340,12 @@ watch(
color: var(--text-secondary);
}
.duration {
font-size: 0.9rem;
color: var(--text-secondary);
white-space: nowrap;
}
.empty-state {
padding: 2rem;
text-align: center;
@ -366,22 +425,6 @@ watch(
font-size: 0.85rem;
}
.exit-code .status-success {
color: #28a745;
}
.exit-code .status-error {
color: #dc3545;
}
.exit-code .status-timeout {
color: #ffc107;
}
.exit-code .status-blocked {
color: #6c757d;
}
.padding {
padding: 1rem;
}

View File

@ -422,6 +422,8 @@ async function handleSubmit(event) {
const response = await startAction(argvs)
if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
router.push(`/logs/${response.executionTrackingId}`)
} else if (popupOnStart.value === 'history') {
router.push(`/action/${props.bindingId}`)
} else {
router.back()
}

View File

@ -640,12 +640,13 @@ func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
return startIdx, endIdx
}
// buildActionLogsResponse builds the response with paginated log entries.
// buildActionLogsResponse builds the response with paginated log entries (newest first).
func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse {
startIdx, endIdx := calculateReversedIndices(page, len(filtered))
ret := &apiv1.GetActionLogsResponse{}
for _, le := range filtered[startIdx:endIdx] {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
chunk := filtered[int(startIdx):int(endIdx)]
for i := len(chunk) - 1; i >= 0; i-- {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(chunk[i], user))
}
ret.CountRemaining = page.start
ret.PageSize = page.size

View File

@ -242,6 +242,8 @@ func sanitizePopupOnStart(raw string, cfg *Config) string {
return raw
case "execution-button":
return raw
case "history":
return raw
default:
return cfg.DefaultPopupOnStart
}

View File

@ -37,6 +37,23 @@ func TestSanitizeConfig(t *testing.T) {
assert.Equal(t, "Waffle", a2.Arguments[0].Choices[0].Title, "Choice title is set to name")
}
func TestSanitizePopupOnStartHistory(t *testing.T) {
c := DefaultConfig()
c.DefaultPopupOnStart = "nothing"
c.Actions = append(c.Actions, &Action{
Title: "With history",
PopupOnStart: "history",
Shell: "true",
})
c.Sanitize()
a := c.findAction("With history")
if assert.NotNil(t, a) {
assert.Equal(t, "history", a.PopupOnStart, "history must be preserved, not replaced by defaultPopupOnStart")
}
}
func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
c := DefaultConfig()