Просмотр исходного кода

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

jamesread 1 месяц назад
Родитель
Сommit
fe68264c2b

+ 12 - 0
docs/modules/ROOT/pages/action_customization/popuponstart.adoc

@@ -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
+----
+
 

+ 1 - 0
docs/modules/ROOT/pages/advanced_configuration/webui.adoc

@@ -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
 

+ 8 - 1
frontend/resources/vue/ActionButton.vue

@@ -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

+ 84 - 41
frontend/resources/vue/views/ActionDetailsView.vue

@@ -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 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 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 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;
 }

+ 2 - 0
frontend/resources/vue/views/ArgumentForm.vue

@@ -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()
     }

+ 4 - 3
service/internal/api/api.go

@@ -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

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

@@ -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
 	}

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

@@ -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()