feat: popupOnStart history, and logs in reverse order on action detail view (#1027)

This commit is contained in:
James Read 2026-05-19 22:29:48 +00:00 committed by GitHub
commit 3c8490fa57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 130 additions and 45 deletions

View File

@ -68,4 +68,16 @@ actions:
image::../executionButtons.png[] 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: 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`) * **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 * **Argument form** — the action opens an argument form on start
* **Run in background** — the action runs without opening a dialog * **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"> <div v-if="navigateOnStart == 'arg'" class="navigate-on-start" title="Opens an argument form on start">
<HugeiconsIcon :icon="TypeCursorIcon" /> <HugeiconsIcon :icon="TypeCursorIcon" />
</div> </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"> <div v-if="navigateOnStart == ''" class="navigate-on-start" title="Run in the background">
<HugeiconsIcon :icon="WorkoutRunIcon" /> <HugeiconsIcon :icon="WorkoutRunIcon" />
</div> </div>
@ -28,7 +31,7 @@ import { buttonResults } from './stores/buttonResults'
import { rateLimits } from './stores/rateLimits' import { rateLimits } from './stores/rateLimits'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue' 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' import { ref, watch, onMounted, onUnmounted, inject, computed } from 'vue'
@ -108,6 +111,8 @@ function constructFromJson(json) {
if (popupOnStart.value.includes('execution-dialog')) { if (popupOnStart.value.includes('execution-dialog')) {
navigateOnStart.value = 'pop' navigateOnStart.value = 'pop'
} else if (popupOnStart.value === 'history') {
navigateOnStart.value = 'hist'
} else if (props.actionData.arguments.length > 0) { } else if (props.actionData.arguments.length > 0) {
navigateOnStart.value = 'arg' navigateOnStart.value = 'arg'
} }
@ -244,6 +249,8 @@ function onLogEntryChanged(logEntry) {
function onExecutionStarted(logEntry) { function onExecutionStarted(logEntry) {
if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) { if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
router.push(`/logs/${logEntry.executionTrackingId}`) router.push(`/logs/${logEntry.executionTrackingId}`)
} else if (popupOnStart.value === 'history') {
router.push(`/action/${bindingId.value}`)
} }
isDisabled.value = true isDisabled.value = true

View File

@ -17,7 +17,7 @@
<dt>Timeout</dt> <dt>Timeout</dt>
<dd>{{ action.timeout }} seconds</dd> <dd>{{ action.timeout }} seconds</dd>
</dl> </dl>
<p v-if="action" class = "fg1"> <p class = "fg1">
Execution history for this action. You can filter by execution tracking ID. Execution history for this action. You can filter by execution tracking ID.
</p> </p>
</div> </div>
@ -47,6 +47,7 @@
<thead> <thead>
<tr> <tr>
<th>Timestamp</th> <th>Timestamp</th>
<th>Duration</th>
<th>Execution ID</th> <th>Execution ID</th>
<th>Metadata</th> <th>Metadata</th>
<th>Status</th> <th>Status</th>
@ -55,6 +56,7 @@
<tbody> <tbody>
<tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle"> <tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
<td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td> <td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
<td class="duration">{{ formatExecutionDuration(log) }}</td>
<td> <td>
<router-link :to="`/logs/${log.executionTrackingId}`"> <router-link :to="`/logs/${log.executionTrackingId}`">
{{ log.executionTrackingId }} {{ log.executionTrackingId }}
@ -70,9 +72,7 @@
</span> </span>
</td> </td>
<td class="exit-code"> <td class="exit-code">
<span :class="getStatusClass(log) + ' annotation'"> <ActionStatusDisplay :logEntry="log" />
{{ getStatusText(log) }}
</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -90,10 +90,11 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import Pagination from 'picocrank/vue/components/Pagination.vue' import Pagination from 'picocrank/vue/components/Pagination.vue'
import Section from 'picocrank/vue/components/Section.vue' import Section from 'picocrank/vue/components/Section.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -105,6 +106,8 @@ const pageSize = ref(10)
const currentPage = ref(1) const currentPage = ref(1)
const loading = ref(false) const loading = ref(false)
const totalCount = ref(0) const totalCount = ref(0)
const durationClock = ref(Date.now())
let durationTicker = null
const filteredLogs = computed(() => { const filteredLogs = computed(() => {
if (!searchText.value) { if (!searchText.value) {
@ -137,6 +140,7 @@ async function fetchActionLogs() {
pageSize.value = serverPageSize pageSize.value = serverPageSize
} }
totalCount.value = Number(response.totalCount) || 0 totalCount.value = Number(response.totalCount) || 0
syncDurationTicker()
} catch (err) { } catch (err) {
console.error('Failed to fetch action logs:', err) console.error('Failed to fetch action logs:', err)
window.showBigError('fetch-action-logs', 'getting action logs', err, false) window.showBigError('fetch-action-logs', 'getting action logs', err, false)
@ -168,6 +172,7 @@ function resetState() {
currentPage.value = 1 currentPage.value = 1
searchText.value = '' searchText.value = ''
loading.value = true loading.value = true
syncDurationTicker()
} }
function clearSearch() { function clearSearch() {
@ -184,19 +189,77 @@ function formatTimestamp(timestamp) {
} }
} }
function getStatusClass(log) { function plural(n, singular, pluralForm) {
if (log.timedOut) return 'status-timeout' return n === 1 ? `1 ${singular}` : `${n} ${pluralForm}`
if (log.blocked) return 'status-blocked'
if (log.exitCode !== 0) return 'status-error'
return 'status-success'
} }
function getStatusText(log) { function formatDurationSimple(ms) {
if (log.timedOut) return 'Timed out' if (!Number.isFinite(ms) || ms < 0) {
if (log.blocked) return 'Blocked' return '—'
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
return 'Completed'
} }
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) { function handlePageChange(page) {
currentPage.value = page currentPage.value = page
@ -246,16 +309,6 @@ watch(
</script> </script>
<style scoped> <style scoped>
.action-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-header h2 {
margin: 0;
}
.icon { .icon {
font-size: 1.5rem; font-size: 1.5rem;
} }
@ -287,6 +340,12 @@ watch(
color: var(--text-secondary); color: var(--text-secondary);
} }
.duration {
font-size: 0.9rem;
color: var(--text-secondary);
white-space: nowrap;
}
.empty-state { .empty-state {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
@ -366,22 +425,6 @@ watch(
font-size: 0.85rem; 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 {
padding: 1rem; padding: 1rem;
} }

View File

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

View File

@ -640,12 +640,13 @@ func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
return startIdx, endIdx 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 { func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse {
startIdx, endIdx := calculateReversedIndices(page, len(filtered)) startIdx, endIdx := calculateReversedIndices(page, len(filtered))
ret := &apiv1.GetActionLogsResponse{} ret := &apiv1.GetActionLogsResponse{}
for _, le := range filtered[startIdx:endIdx] { chunk := filtered[int(startIdx):int(endIdx)]
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user)) for i := len(chunk) - 1; i >= 0; i-- {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(chunk[i], user))
} }
ret.CountRemaining = page.start ret.CountRemaining = page.start
ret.PageSize = page.size ret.PageSize = page.size

View File

@ -242,6 +242,8 @@ func sanitizePopupOnStart(raw string, cfg *Config) string {
return raw return raw
case "execution-button": case "execution-button":
return raw return raw
case "history":
return raw
default: default:
return cfg.DefaultPopupOnStart 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") 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) { func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
c := DefaultConfig() c := DefaultConfig()