LogsListView.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. <template>
  2. <Section :title="t('logs.title')" :padding="false">
  3. <template #toolbar>
  4. <router-link to="/logs/queue" class="button neutral">
  5. {{ t('logs.queue') }}
  6. </router-link>
  7. <router-link to="/logs/calendar" class="button neutral">
  8. {{ t('logs.calendar') }}
  9. </router-link>
  10. <label class="input-with-icons">
  11. <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
  12. <path fill="currentColor"
  13. 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" />
  14. </svg>
  15. <input
  16. :placeholder="t('logs.filter-placeholder')"
  17. v-model="searchText"
  18. list="logs-filter-suggestions"
  19. :aria-invalid="filterError ? 'true' : 'false'"
  20. />
  21. <datalist id="logs-filter-suggestions">
  22. <option v-for="suggestion in filterSuggestions" :key="suggestion" :value="suggestion" />
  23. </datalist>
  24. <button :title="t('logs.clear-filter')" :disabled="!searchText" @click="clearSearch">
  25. <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
  26. <path fill="currentColor"
  27. 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" />
  28. </svg>
  29. </button>
  30. </label>
  31. </template>
  32. <div class="padding logs-intro">
  33. <p>{{ t('logs.page-description') }}</p>
  34. <details class="filter-help">
  35. <summary>{{ t('logs.filter-help-title') }}</summary>
  36. <p>{{ t('logs.filter-help-intro') }}</p>
  37. <p>{{ t('logs.filter-help-fields') }}</p>
  38. <p><code>{{ t('logs.filter-help-examples') }}</code></p>
  39. </details>
  40. <p v-if="filterError" class="filter-error" role="alert">{{ filterError }}</p>
  41. </div>
  42. <div v-show="logs.length > 0">
  43. <table class="logs-table row-hover">
  44. <thead>
  45. <tr>
  46. <th>
  47. <div class="timestamp-header">
  48. <span>{{ t('logs.timestamp') }}</span>
  49. <span v-if="selectedDate" class="date-filter-indicator">
  50. <span class="date-filter-text">{{ formatDateFilter(selectedDate) }}</span>
  51. <button :title="t('logs.clear-date-filter')" @click="clearDateFilter" class="clear-date-button">
  52. <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
  53. <path fill="currentColor"
  54. 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" />
  55. </svg>
  56. </button>
  57. </span>
  58. </div>
  59. </th>
  60. <th>{{ t('logs.action') }}</th>
  61. <th>{{ t('logs.metadata') }}</th>
  62. <th>{{ t('logs.status') }}</th>
  63. </tr>
  64. </thead>
  65. <tbody>
  66. <tr v-for="log in logs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
  67. <td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
  68. <td>
  69. <ActionIconGlyph class="icon" :glyph="log.actionIcon" />
  70. <router-link :to="`/logs/${log.executionTrackingId}`">
  71. <LogActionTitle :action-title="log.actionTitle" :justification="log.justification" />
  72. </router-link>
  73. </td>
  74. <td class="tags">
  75. <span class="annotation">
  76. <span class="annotation-key">User:</span>
  77. <span class="annotation-val">{{ log.user }}</span>
  78. </span>
  79. <span v-if="log.tags && log.tags.length > 0" class="tag-list">
  80. <span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
  81. </span>
  82. </td>
  83. <td class="exit-code">
  84. <ActionStatusDisplay :logEntry="log" />
  85. </td>
  86. </tr>
  87. </tbody>
  88. </table>
  89. <Pagination :pageSize="pageSize" :total="totalCount" :currentPage="currentPage" :page="currentPage" @page-change="handlePageChange" class = "padding"
  90. @page-size-change="handlePageSizeChange" itemTitle="execution logs" />
  91. </div>
  92. <div v-show="logs.length === 0 && !loading && searchText && !filterError" class="empty-state padding">
  93. <p>{{ t('logs.no-logs-for-filter') }}</p>
  94. <button @click="clearSearch" class="button neutral">
  95. {{ t('logs.clear-filter') }}
  96. </button>
  97. </div>
  98. <div v-show="selectedDate && logs.length === 0 && !loading && !searchText" class="empty-state padding">
  99. <p>{{ t('logs.no-logs-to-display') }} {{ formatDateFilter(selectedDate) }}.</p>
  100. <button @click="clearDateFilter" class="button neutral">
  101. {{ t('logs.clear-date-filter') }}
  102. </button>
  103. </div>
  104. <div v-show="logs.length === 0 && !loading && !selectedDate && !searchText" class="empty-state padding">
  105. <p>{{ t('logs.no-logs-to-display') }}</p>
  106. <router-link to="/">{{ t('return-to-index') }}</router-link>
  107. </div>
  108. </Section>
  109. </template>
  110. <script setup>
  111. import { ref, onMounted, onUnmounted, watch } from 'vue'
  112. import { useRoute, useRouter } from 'vue-router'
  113. import { ConnectError, Code } from '@connectrpc/connect'
  114. import Pagination from 'picocrank/vue/components/Pagination.vue'
  115. import Section from 'picocrank/vue/components/Section.vue'
  116. import { useI18n } from 'vue-i18n'
  117. import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
  118. import ActionIconGlyph from '../components/ActionIconGlyph.vue'
  119. import LogActionTitle from '../components/LogActionTitle.vue'
  120. import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
  121. const route = useRoute()
  122. const router = useRouter()
  123. const logs = ref([])
  124. const searchText = ref('')
  125. const pageSize = ref(10)
  126. const currentPage = ref(1)
  127. const loading = ref(false)
  128. const totalCount = ref(0)
  129. const selectedDate = ref(null)
  130. const filterError = ref('')
  131. let fetchTimer = null
  132. const filterSuggestions = [
  133. '!Update',
  134. 'Status != Completed',
  135. 'Status == Blocked',
  136. 'Status == Running',
  137. 'Action contains backup',
  138. 'User == guest'
  139. ]
  140. const { t } = useI18n()
  141. function updateDateFromRoute() {
  142. const dateParam = route.query.date
  143. if (dateParam) {
  144. selectedDate.value = dateParam
  145. } else {
  146. selectedDate.value = null
  147. }
  148. fetchLogs()
  149. }
  150. watch(() => route.query.date, () => {
  151. updateDateFromRoute()
  152. })
  153. watch(searchText, () => {
  154. currentPage.value = 1
  155. scheduleFetchLogs()
  156. })
  157. async function fetchLogs() {
  158. loading.value = true
  159. filterError.value = ''
  160. try {
  161. const startOffset = (currentPage.value - 1) * pageSize.value
  162. const args = {
  163. startOffset: BigInt(startOffset),
  164. pageSize: BigInt(pageSize.value)
  165. }
  166. if (selectedDate.value) {
  167. args.dateFilter = selectedDate.value
  168. }
  169. if (searchText.value.trim()) {
  170. args.filter = searchText.value.trim()
  171. }
  172. const response = await window.client.getLogs(args)
  173. logs.value = response.logs
  174. totalCount.value = Number(response.totalCount) || 0
  175. } catch (err) {
  176. console.error('Failed to fetch logs:', err)
  177. if (err instanceof ConnectError && err.code === Code.InvalidArgument && searchText.value.trim()) {
  178. filterError.value = `${t('logs.filter-error')} ${err.message}`
  179. logs.value = []
  180. totalCount.value = 0
  181. return
  182. }
  183. window.showBigError('fetch-logs', 'getting logs', err, false)
  184. } finally {
  185. loading.value = false
  186. }
  187. }
  188. function scheduleFetchLogs() {
  189. if (fetchTimer) {
  190. clearTimeout(fetchTimer)
  191. }
  192. fetchTimer = setTimeout(() => {
  193. fetchLogs()
  194. }, 400)
  195. }
  196. function clearSearch() {
  197. searchText.value = ''
  198. }
  199. function clearDateFilter() {
  200. selectedDate.value = null
  201. const query = { ...route.query }
  202. delete query.date
  203. router.push({ path: route.path, query })
  204. }
  205. function formatDateFilter(dateString) {
  206. try {
  207. const date = new Date(dateString + 'T00:00:00')
  208. return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
  209. } catch (err) {
  210. return dateString
  211. }
  212. }
  213. function formatTimestamp(timestamp) {
  214. if (!timestamp) return 'Unknown'
  215. try {
  216. const date = new Date(timestamp)
  217. return date.toLocaleString()
  218. } catch (err) {
  219. return timestamp
  220. }
  221. }
  222. function handlePageChange(page) {
  223. currentPage.value = page
  224. fetchLogs()
  225. }
  226. function handlePageSizeChange(newPageSize) {
  227. pageSize.value = newPageSize
  228. currentPage.value = 1
  229. fetchLogs()
  230. }
  231. function onExecutionEvent(evt) {
  232. const logEntry = getExecutionLogEntry(evt)
  233. if (!logEntry) {
  234. return
  235. }
  236. if (!updateLogEntryInList(logs.value, logEntry)) {
  237. fetchLogs()
  238. }
  239. }
  240. onMounted(() => {
  241. updateDateFromRoute()
  242. window.addEventListener('EventExecutionStarted', onExecutionEvent)
  243. window.addEventListener('EventExecutionFinished', onExecutionEvent)
  244. })
  245. onUnmounted(() => {
  246. window.removeEventListener('EventExecutionStarted', onExecutionEvent)
  247. window.removeEventListener('EventExecutionFinished', onExecutionEvent)
  248. if (fetchTimer) {
  249. clearTimeout(fetchTimer)
  250. fetchTimer = null
  251. }
  252. })
  253. </script>
  254. <style scoped>
  255. .logs-intro {
  256. display: flex;
  257. flex-direction: column;
  258. gap: 0.75rem;
  259. }
  260. .logs-intro p {
  261. margin: 0;
  262. }
  263. .filter-help summary {
  264. cursor: pointer;
  265. font-weight: 600;
  266. }
  267. .filter-help code {
  268. display: block;
  269. white-space: pre-wrap;
  270. margin-top: 0.5rem;
  271. }
  272. .filter-error {
  273. color: var(--karma-bad-fg, #b00020);
  274. }
  275. .input-with-icons {
  276. display: flex;
  277. align-items: center;
  278. gap: 0.5rem;
  279. padding: 0.5rem;
  280. border: 1px solid var(--border-color);
  281. border-radius: 0.25rem;
  282. background: var(--section-background);
  283. width: 100%;
  284. max-width: 360px;
  285. }
  286. .input-with-icons input {
  287. border: none;
  288. outline: none;
  289. background: transparent;
  290. flex: 1;
  291. color: var(--text-primary);
  292. }
  293. .input-with-icons button {
  294. background: none;
  295. border: none;
  296. cursor: pointer;
  297. padding: 0.25rem;
  298. border-radius: 3px;
  299. }
  300. .input-with-icons button:disabled {
  301. opacity: 0.5;
  302. cursor: not-allowed;
  303. }
  304. .timestamp {
  305. font-family: monospace;
  306. font-size: 0.875rem;
  307. color: #666;
  308. }
  309. .icon {
  310. margin-right: 0.5rem;
  311. font-size: 1.2em;
  312. }
  313. .annotation {
  314. font-weight: 500;
  315. font-size: smaller;
  316. }
  317. .empty-state {
  318. text-align: center;
  319. padding: 2rem;
  320. color: #666;
  321. }
  322. .empty-state a {
  323. color: #007bff;
  324. text-decoration: none;
  325. }
  326. .empty-state a:hover {
  327. text-decoration: underline;
  328. }
  329. .timestamp-header {
  330. display: flex;
  331. flex-direction: column;
  332. gap: 0.25rem;
  333. }
  334. .date-filter-indicator {
  335. display: flex;
  336. align-items: center;
  337. gap: 0.25rem;
  338. font-size: 0.75rem;
  339. font-weight: normal;
  340. color: var(--text-secondary, #666);
  341. white-space: nowrap;
  342. }
  343. .date-filter-text {
  344. font-style: italic;
  345. }
  346. .timestamp-header .clear-date-button {
  347. background: none;
  348. border: none;
  349. cursor: pointer;
  350. padding: 0.125rem;
  351. border-radius: 3px;
  352. display: flex;
  353. align-items: center;
  354. flex-shrink: 0;
  355. opacity: 0.7;
  356. transition: opacity 0.2s;
  357. }
  358. .timestamp-header .clear-date-button:hover {
  359. opacity: 1;
  360. background: var(--hover-background, rgba(0, 0, 0, 0.05));
  361. }
  362. .timestamp-header .clear-date-button svg {
  363. width: 0.75rem;
  364. height: 0.75rem;
  365. }
  366. </style>