ActionDetailsView.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. <template>
  2. <Section :padding="false">
  3. <template #title>
  4. <span class="section-title-with-icon">
  5. Action Details:
  6. <ActionIconGlyph v-if="action" class="action-title-icon" :glyph="action.icon" />
  7. {{ actionTitle }}
  8. </span>
  9. </template>
  10. <template #toolbar>
  11. <div class="action-details-toolbar">
  12. <button
  13. v-for="dashboard in backToDashboards"
  14. :key="dashboard.path"
  15. @click="goToDashboard(dashboard.path)"
  16. :title="'Back to ' + dashboard.title"
  17. class="button neutral"
  18. >
  19. <HugeiconsIcon :icon="DashboardSquare01Icon" />
  20. {{ dashboard.title }}
  21. </button>
  22. <button v-if="action" @click="startAction" title="Run this action" class="button neutral">
  23. <HugeiconsIcon :icon="WorkoutRunIcon" />
  24. Run
  25. </button>
  26. <router-link
  27. v-if="action"
  28. :to="{ name: 'ActionExecConditions', params: { actionId: route.params.actionId } }"
  29. class="button neutral"
  30. title="View configured automatic triggers and on-demand execution"
  31. >
  32. Execution conditions ({{ executionConditionCount }})
  33. </router-link>
  34. </div>
  35. </template>
  36. <div class = "flex-row padding" v-if="action">
  37. <div class = "fg1">
  38. <dl>
  39. <dt>Timeout</dt>
  40. <dd>{{ action.timeout }} seconds</dd>
  41. <template v-if="actionGroups.length > 0">
  42. <dt>
  43. <router-link :to="{ name: 'LogsQueue' }" class="action-groups-link">Action groups</router-link>
  44. </dt>
  45. <dd>
  46. <ul class="action-group-list">
  47. <li v-for="group in actionGroups" :key="group.name" class="action-group-row">
  48. <router-link :to="{ name: 'LogsQueue' }" class="action-groups-link action-group-name">{{ group.name }}</router-link><template v-if="group.maxConcurrent > 0 && group.queueSize > 0"> - </template><ActionGroupLimitsLabel
  49. :max-concurrent="group.maxConcurrent"
  50. :queue-size="group.queueSize"
  51. />
  52. </li>
  53. </ul>
  54. </dd>
  55. </template>
  56. </dl>
  57. <p class = "fg1">
  58. Execution history for this action. You can filter by execution tracking ID.
  59. </p>
  60. </div>
  61. <div style = "align-self: start; text-align: right;">
  62. <div class="filter-container">
  63. <label class="input-with-icons">
  64. <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
  65. <path fill="currentColor"
  66. d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
  67. </svg>
  68. <input placeholder="Filter current page" v-model="searchText" />
  69. <button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
  70. <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
  71. <path fill="currentColor"
  72. d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
  73. </svg>
  74. </button>
  75. </label>
  76. </div>
  77. </div>
  78. </div>
  79. <div v-show="filteredLogs.length > 0">
  80. <table class="logs-table row-hover">
  81. <thead>
  82. <tr>
  83. <th>Timestamp</th>
  84. <th>Duration</th>
  85. <th>Execution ID</th>
  86. <th>Metadata</th>
  87. <th>Status</th>
  88. </tr>
  89. </thead>
  90. <tbody>
  91. <tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
  92. <td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
  93. <td class="duration">{{ formatExecutionDuration(log) }}</td>
  94. <td>
  95. <router-link :to="`/logs/${log.executionTrackingId}`">
  96. {{ log.executionTrackingId }}
  97. </router-link>
  98. </td>
  99. <td class="tags">
  100. <span class="annotation">
  101. <span class="annotation-key">User:</span>
  102. <span class="annotation-val">{{ log.user }}</span>
  103. </span>
  104. <span v-if="log.tags && log.tags.length > 0" class="tag-list">
  105. <span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
  106. </span>
  107. </td>
  108. <td class="exit-code">
  109. <ActionStatusDisplay :logEntry="log" :link-queued-status="true" />
  110. </td>
  111. </tr>
  112. </tbody>
  113. </table>
  114. <Pagination :pageSize="pageSize" :total="totalCount" :currentPage="currentPage" :page="currentPage" @page-change="handlePageChange" class="padding"
  115. @page-size-change="handlePageSizeChange" itemTitle="execution logs" />
  116. </div>
  117. <div v-show="logs.length === 0 && !loading" class="empty-state">
  118. <p>This action has no execution history.</p>
  119. <router-link to="/">Return to index</router-link>
  120. </div>
  121. </Section>
  122. </template>
  123. <script setup>
  124. import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
  125. import { useRoute, useRouter } from 'vue-router'
  126. import Pagination from 'picocrank/vue/components/Pagination.vue'
  127. import Section from 'picocrank/vue/components/Section.vue'
  128. import ActionIconGlyph from '../components/ActionIconGlyph.vue'
  129. import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
  130. import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
  131. import { HugeiconsIcon } from '@hugeicons/vue'
  132. import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
  133. import { requestReconnectNow } from '../../../js/websocket.js'
  134. import { needsArgumentForm } from '../utils/needsArgumentForm.js'
  135. import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
  136. import { countExecutionConditions } from '../utils/executionConditionCount.js'
  137. const route = useRoute()
  138. const router = useRouter()
  139. const logs = ref([])
  140. const action = ref(null)
  141. const backToDashboards = ref([])
  142. const actionTitle = ref('Action Details')
  143. const searchText = ref('')
  144. const pageSize = ref(10)
  145. const currentPage = ref(1)
  146. const loading = ref(false)
  147. const totalCount = ref(0)
  148. const durationClock = ref(Date.now())
  149. let durationTicker = null
  150. const filteredLogs = computed(() => {
  151. if (!searchText.value) {
  152. return logs.value
  153. }
  154. const searchLower = searchText.value.toLowerCase()
  155. return logs.value.filter(log =>
  156. log.executionTrackingId.toLowerCase().includes(searchLower) ||
  157. log.actionTitle.toLowerCase().includes(searchLower)
  158. )
  159. })
  160. const executionConditionCount = computed(() => countExecutionConditions(action.value))
  161. const actionGroups = computed(() => action.value?.groups ?? [])
  162. async function fetchActionLogs() {
  163. loading.value = true
  164. try {
  165. const actionId = route.params.actionId
  166. const startOffset = (currentPage.value - 1) * pageSize.value
  167. const args = {
  168. "actionId": actionId,
  169. "startOffset": BigInt(startOffset),
  170. "pageSize": BigInt(Number(pageSize.value)),
  171. }
  172. const response = await window.client.getActionLogs(args)
  173. logs.value = response.logs
  174. const serverPageSize = Number(response.pageSize)
  175. if (Number.isFinite(serverPageSize) && serverPageSize > 0) {
  176. pageSize.value = serverPageSize
  177. }
  178. totalCount.value = Number(response.totalCount) || 0
  179. syncDurationTicker()
  180. } catch (err) {
  181. console.error('Failed to fetch action logs:', err)
  182. window.showBigError('fetch-action-logs', 'getting action logs', err, false)
  183. } finally {
  184. loading.value = false
  185. }
  186. }
  187. async function fetchAction() {
  188. try {
  189. const actionId = route.params.actionId
  190. const args = {
  191. "bindingId": actionId
  192. }
  193. const response = await window.client.getActionBinding(args)
  194. action.value = response.action
  195. backToDashboards.value = (response.backToDashboards || []).slice(0, 3)
  196. actionTitle.value = action.value?.title || 'Unknown Action'
  197. } catch (err) {
  198. console.error('Failed to fetch action:', err)
  199. window.showBigError('fetch-action', 'getting action details', err, false)
  200. }
  201. }
  202. function goToDashboard(path) {
  203. router.push(path)
  204. }
  205. function resetState() {
  206. action.value = null
  207. backToDashboards.value = []
  208. actionTitle.value = 'Action Details'
  209. logs.value = []
  210. totalCount.value = 0
  211. currentPage.value = 1
  212. searchText.value = ''
  213. loading.value = true
  214. syncDurationTicker()
  215. }
  216. function clearSearch() {
  217. searchText.value = ''
  218. }
  219. function formatTimestamp(timestamp) {
  220. if (!timestamp) return 'Unknown'
  221. try {
  222. const date = new Date(timestamp)
  223. return date.toLocaleString()
  224. } catch (err) {
  225. return timestamp
  226. }
  227. }
  228. function plural(n, singular, pluralForm) {
  229. return n === 1 ? `1 ${singular}` : `${n} ${pluralForm}`
  230. }
  231. function formatDurationSimple(ms) {
  232. if (!Number.isFinite(ms) || ms < 0) {
  233. return '—'
  234. }
  235. const totalSec = Math.round(ms / 1000)
  236. if (totalSec === 0) {
  237. return '0 seconds'
  238. }
  239. const days = Math.floor(totalSec / 86400)
  240. const hours = Math.floor((totalSec % 86400) / 3600)
  241. const minutes = Math.floor((totalSec % 3600) / 60)
  242. const seconds = totalSec % 60
  243. const parts = []
  244. if (days > 0) parts.push(plural(days, 'day', 'days'))
  245. if (hours > 0) parts.push(plural(hours, 'hour', 'hours'))
  246. if (minutes > 0) parts.push(plural(minutes, 'minute', 'minutes'))
  247. if (seconds > 0) parts.push(plural(seconds, 'second', 'seconds'))
  248. return parts.join(' ')
  249. }
  250. function formatExecutionDuration(log) {
  251. // Reading durationClock keeps this column reactive while executions are in progress.
  252. const clock = durationClock.value
  253. if (!log?.datetimeStarted) {
  254. return '—'
  255. }
  256. const started = new Date(log.datetimeStarted)
  257. if (Number.isNaN(started.getTime())) {
  258. return '—'
  259. }
  260. let endMs
  261. if (log.executionFinished) {
  262. const finished = new Date(log.datetimeFinished)
  263. if (Number.isNaN(finished.getTime())) {
  264. return '—'
  265. }
  266. endMs = finished.getTime()
  267. } else {
  268. endMs = clock
  269. }
  270. return formatDurationSimple(endMs - started.getTime())
  271. }
  272. function syncDurationTicker() {
  273. if (durationTicker != null) {
  274. clearInterval(durationTicker)
  275. durationTicker = null
  276. }
  277. const hasRunning = logs.value.some(l => !l.executionFinished)
  278. if (!hasRunning) {
  279. return
  280. }
  281. durationTicker = window.setInterval(() => {
  282. durationClock.value = Date.now()
  283. }, 1000)
  284. }
  285. function handlePageChange(page) {
  286. currentPage.value = page
  287. fetchActionLogs()
  288. }
  289. function handlePageSizeChange(newPageSize) {
  290. pageSize.value = newPageSize
  291. currentPage.value = 1
  292. fetchActionLogs()
  293. }
  294. async function startAction() {
  295. if (!action.value || !action.value.bindingId) {
  296. console.error('Cannot start action: no binding ID')
  297. return
  298. }
  299. if (needsArgumentForm(action.value)) {
  300. router.push(`/actionBinding/${action.value.bindingId}/argumentForm`)
  301. return
  302. }
  303. try {
  304. requestReconnectNow()
  305. const args = {
  306. bindingId: action.value.bindingId,
  307. arguments: []
  308. }
  309. const response = await window.client.startAction(args)
  310. router.push(`/logs/${response.executionTrackingId}`)
  311. } catch (err) {
  312. console.error('Failed to start action:', err)
  313. window.showBigError('start-action', 'starting action', err, false)
  314. }
  315. }
  316. function onExecutionEvent(evt) {
  317. const logEntry = getExecutionLogEntry(evt)
  318. if (!logEntry || logEntry.bindingId !== route.params.actionId) {
  319. return
  320. }
  321. if (!updateLogEntryInList(logs.value, logEntry)) {
  322. fetchActionLogs()
  323. return
  324. }
  325. syncDurationTicker()
  326. }
  327. onMounted(() => {
  328. fetchAction()
  329. fetchActionLogs()
  330. window.addEventListener('EventExecutionStarted', onExecutionEvent)
  331. window.addEventListener('EventExecutionFinished', onExecutionEvent)
  332. })
  333. watch(
  334. () => route.params.actionId,
  335. () => {
  336. resetState()
  337. fetchAction()
  338. fetchActionLogs()
  339. },
  340. { immediate: false }
  341. )
  342. onUnmounted(() => {
  343. window.removeEventListener('EventExecutionStarted', onExecutionEvent)
  344. window.removeEventListener('EventExecutionFinished', onExecutionEvent)
  345. if (durationTicker != null) {
  346. clearInterval(durationTicker)
  347. durationTicker = null
  348. }
  349. })
  350. </script>
  351. <style scoped>
  352. .section-title-with-icon {
  353. display: inline-flex;
  354. align-items: center;
  355. gap: 0.5rem;
  356. }
  357. .action-title-icon {
  358. font-size: 1.5rem;
  359. }
  360. .logs-table {
  361. width: 100%;
  362. border-collapse: collapse;
  363. }
  364. .logs-table th {
  365. background-color: var(--section-background);
  366. padding: 0.5rem;
  367. text-align: left;
  368. font-weight: 600;
  369. }
  370. .logs-table td {
  371. padding: 0.5rem;
  372. border-top: 1px solid var(--border-color);
  373. }
  374. .log-row:hover {
  375. background-color: var(--hover-background);
  376. }
  377. .timestamp {
  378. font-family: monospace;
  379. font-size: 0.9rem;
  380. color: var(--text-secondary);
  381. }
  382. .duration {
  383. font-size: 0.9rem;
  384. color: var(--text-secondary);
  385. white-space: nowrap;
  386. }
  387. .empty-state {
  388. padding: 2rem;
  389. text-align: center;
  390. color: var(--text-secondary);
  391. }
  392. .filter-container {
  393. display: flex;
  394. justify-content: flex-end;
  395. padding: 0.5rem 1rem;
  396. }
  397. .input-with-icons {
  398. display: flex;
  399. align-items: center;
  400. gap: 0.5rem;
  401. padding: 0.5rem;
  402. border: 1px solid var(--border-color);
  403. border-radius: 0.25rem;
  404. background: var(--section-background);
  405. width: 100%;
  406. max-width: 300px;
  407. }
  408. .input-with-icons input {
  409. border: none;
  410. outline: none;
  411. background: transparent;
  412. flex: 1;
  413. color: var(--text-primary);
  414. }
  415. .input-with-icons button {
  416. background: none;
  417. border: none;
  418. cursor: pointer;
  419. color: var(--text-secondary);
  420. }
  421. .input-with-icons button:disabled {
  422. opacity: 0.3;
  423. cursor: not-allowed;
  424. }
  425. .tags {
  426. display: flex;
  427. flex-wrap: wrap;
  428. gap: 0.5rem;
  429. }
  430. .annotation {
  431. display: inline-flex;
  432. align-items: center;
  433. gap: 0.25rem;
  434. font-size: 0.85rem;
  435. }
  436. .annotation-key {
  437. font-weight: 600;
  438. color: var(--text-secondary);
  439. }
  440. .annotation-val {
  441. color: var(--text-primary);
  442. }
  443. .tag-list {
  444. display: inline-flex;
  445. gap: 0.25rem;
  446. }
  447. .tag {
  448. background-color: var(--accent-color);
  449. color: var(--accent-text);
  450. padding: 0.1rem 0.5rem;
  451. border-radius: 0.25rem;
  452. font-size: 0.85rem;
  453. }
  454. .padding {
  455. padding: 1rem;
  456. }
  457. .action-details-toolbar {
  458. display: inline-flex;
  459. flex-wrap: wrap;
  460. gap: 0.5rem;
  461. align-items: center;
  462. }
  463. .action-group-list {
  464. margin: 0;
  465. padding-left: 0;
  466. list-style: none;
  467. }
  468. .action-group-row {
  469. padding: 0.25rem 0;
  470. }
  471. .action-group-name {
  472. font-family: monospace;
  473. }
  474. .action-groups-link {
  475. color: inherit;
  476. text-decoration: none;
  477. }
  478. .action-groups-link:hover {
  479. text-decoration: underline;
  480. color: var(--link-color, #007bff);
  481. }
  482. </style>