feat: popupOnStart history, and logs in reverse order on action details view
This commit is contained in:
parent
be1db222bb
commit
fe68264c2b
|
|
@ -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
|
||||||
|
----
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,20 +189,78 @@ 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
|
||||||
fetchActionLogs()
|
fetchActionLogs()
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue