client/containers/workflow-history/component.vue (956 lines of code) (raw):

<script> // Copyright (c) 2017-2024 Uber Technologies Inc. // // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import Prism from 'vue-prism-component'; import { DynamicScroller, DynamicScrollerItem, RecycleScroller, } from 'vue-virtual-scroller'; import debounce from 'lodash-es/debounce'; import omit from 'lodash-es/omit'; import { EventDetail, FooterToolbar, Timeline, WorkflowGraph, } from './components'; import { GRAPH_VIEW_DAG, GRAPH_VIEW_TIMELINE } from './constants'; import { getDefaultSplitSize } from './helpers'; import { DetailList, FeatureFlag, HighlightToggle, SelectInput, } from '~components'; import { httpService } from '~services'; export default { name: 'history', data() { return { compactDetails: localStorage.getItem(`${this.domain}:history-compact-details`) === 'true', scrolledToEventOnInit: false, splitEnabled: false, eventType: 'All', eventTypes: [ { value: 'All', label: 'All' }, { value: 'Decision', label: 'Decision' }, { value: 'Activity', label: 'Activity' }, { value: 'Signal', label: 'Signal' }, { value: 'Timer', label: 'Timer' }, { value: 'ChildWorkflow', label: 'ChildWorkflow' }, { value: 'Workflow', label: 'Workflow' }, ], splitSizeSet: [1, 99], splitSizeMinSet: [0, 0], unwatch: [], // allows render method to reference constants GRAPH_VIEW_DAG: GRAPH_VIEW_DAG, GRAPH_VIEW_TIMELINE: GRAPH_VIEW_TIMELINE, }; }, props: [ 'baseAPIURL', 'clusterName', 'domain', 'eventId', 'events', 'format', 'graphEnabled', 'graphView', 'isWorkflowRunning', 'loading', 'pendingTaskCount', 'runId', 'timelineEvents', 'workflowHistoryEventHighlightList', 'workflowHistoryEventHighlightListEnabled', 'workflowId', ], created() { this.onResizeWindow = debounce(() => { const { scrollerCompact, scrollerGrid, viewSplit } = this.$refs; const scroller = this.isGrid ? scrollerGrid : scrollerCompact; if (!scroller) { return; } const offsetHeight = this.isGrid ? 60 + 38 : 0; const viewSplitHeight = viewSplit.$el.offsetHeight; const scrollerHeight = viewSplitHeight - offsetHeight; scroller.$el.style.height = `${scrollerHeight}px`; }, 5); }, mounted() { this.setSplitSize(); this.unwatch.push( this.$watch( () => `${this.events.length}${this.$route.query.format}${this.compactDetails}`, this.onResizeWindow, { immediate: true } ) ); window.addEventListener('resize', this.onResizeWindow); }, beforeDestroy() { window.removeEventListener('resize', this.onResizeWindow); while (this.unwatch.length) { this.unwatch.pop()(); } }, computed: { selectedViewingFormat() { return ( this.format || localStorage.getItem(`${this.domain}:history-viewing-format`) || 'compact' ); }, exportFilename() { return `${this.workflowId.replace(/[\\~#%&*{}/:<>?|"-]/g, ' ')} - ${ this.runId }.json`; }, filteredEvents() { const { eventId, eventType } = this; const formattedEvents = this.events.map(event => ({ ...event, expanded: event.eventId === eventId, })); return eventType && eventType !== 'All' ? formattedEvents.filter(result => result.eventType.includes(eventType)) : formattedEvents; }, filteredEventIdToIndex() { return this.filteredEvents .map(({ eventId }) => eventId) .reduce((accumulator, eventId, index) => { accumulator[eventId] = index; return accumulator; }, {}); }, isGrid() { return this.selectedViewingFormat === 'grid'; }, selectedTimelineEvent() { return this.timelineEvents.find(te => te.eventIds.includes(this.eventId)); }, selectedEvent() { return this.events.find(e => e.eventId === this.eventId); }, selectedEventDetails() { if (!this.selectedEvent) { return {}; } return { timestamp: this.selectedEvent.timeStampDisplay, eventId: this.selectedEvent.eventId, ...this.selectedEvent.details, }; }, showTable() { return this.loading || this.events.length; }, showNoResults() { return !this.loading && this.events.length === 0; }, splitDirection() { return this.graphView === GRAPH_VIEW_DAG ? 'horizontal' : 'vertical'; }, timelineEventIdToIndex() { return this.timelineEvents .map(({ eventIds }) => eventIds) .reduce( (accumulator, eventIds, index) => ({ ...accumulator, ...eventIds.reduce((acc, eventId) => { acc[eventId] = index; return acc; }, {}), }), {} ); }, }, methods: { setSplitSize() { const { graphView } = this; this.splitSizeSet = getDefaultSplitSize({ graphView }); this.onSplitResize(); }, deselectEvent() { this.$router.replace({ query: omit(this.$route.query, 'eventId') }); }, enableSplitting() { if (!this.splitEnabled && this.graphView === GRAPH_VIEW_TIMELINE) { const timelineHeightPct = (this.$refs.splitPanel.$el.firstElementChild.offsetHeight / this.$refs.splitPanel.$el.offsetHeight) * 100; this.splitSizeSet = [timelineHeightPct, 100 - timelineHeightPct]; this.splitEnabled = true; } }, onSplitResize: debounce(() => { window.dispatchEvent(new Event('resize')); }, 5), onWorkflowHistoryEventParamToggle(event) { this.$emit('onWorkflowHistoryEventParamToggle', event); }, setEventType(et) { this.eventType = et.value; setTimeout(() => this.scrollEventIntoView(this.eventId), 100); }, setFormat(format) { this.$router.replace({ query: { ...this.$route.query, format }, }); if (typeof format === 'string') { localStorage.setItem(`${this.domain}:history-viewing-format`, format); } setTimeout(() => this.scrollEventIntoView(this.eventId), 100); }, setCompactDetails(compact) { const { scrollerGrid } = this.$refs; this.compactDetails = compact; localStorage.setItem( `${this.domain}:history-compact-details`, JSON.stringify(compact) ); scrollerGrid.forceUpdate(); setTimeout(() => this.scrollEventIntoView(this.eventId), 100); }, scrollEventIntoView(eventId) { const index = this.isGrid ? this.filteredEventIdToIndex[eventId] : this.timelineEventIdToIndex[eventId]; this.scrollToItem(index); if (this.isGrid) { // Need to fire twice as the scroller items can have dynamic height which causes the scrolling position to not be accurate. setTimeout(() => this.scrollToItem(index, true), 100); } }, scrollToItem(index, forceUpdate) { const { scrollerCompact, scrollerGrid } = this.$refs; const scroller = this.isGrid ? scrollerGrid : scrollerCompact; if (index === undefined || !scroller) { return; } try { scroller.scrollToItem(index); if (forceUpdate) { scroller.forceUpdate(); } } catch (error) { // eslint-disable-next-line no-console console.warn('vue-virtual-scroller: Could not scrollToItem:', error); } }, selectTimelineEvent(i) { this.$router.replaceQueryParam( 'eventId', i.eventIds[i.eventIds.length - 1] ); }, toggleShowDagGraph() { if (this.graphView === GRAPH_VIEW_DAG) { this.$router.replace({ query: omit(this.$route.query, 'graphView'), }); } else { this.$router.replace({ query: { ...this.$route.query, graphView: GRAPH_VIEW_DAG }, }); } }, toggleShowTimeline() { if (this.graphView === GRAPH_VIEW_TIMELINE) { this.$router.replace({ query: omit(this.$route.query, 'graphView'), }); } else { this.$router.replace({ query: { ...this.$route.query, graphView: GRAPH_VIEW_TIMELINE }, }); } }, exportHistory(e) { const target = e.target; httpService.get(this.baseAPIURL + '/export').then(historyJson => { const blob = new Blob([JSON.stringify(historyJson)], { type: 'application/json', }); target.href = window.URL.createObjectURL(blob); target.download = this.exportFilename; target.click(); }); return false; }, }, watch: { eventId(eventId) { this.scrollEventIntoView(eventId); }, filteredEvents() { if ( !this.scrolledToEventOnInit && this.eventId !== undefined && this.filteredEventIdToIndex[this.eventId] !== undefined ) { this.scrolledToEventOnInit = true; setTimeout(() => this.scrollEventIntoView(this.eventId), 100); } }, graphView() { this.setSplitSize(); }, }, components: { 'detail-list': DetailList, DynamicScroller, DynamicScrollerItem, 'event-detail': EventDetail, 'feature-flag': FeatureFlag, 'footer-toolbar': FooterToolbar, 'highlight-toggle': HighlightToggle, prism: Prism, RecycleScroller, 'select-input': SelectInput, WorkflowGraph, timeline: Timeline, }, }; </script> <template> <section :class="{ history: true, loading, 'has-results': !!events.length, 'split-enabled': true, }" > <header class="controls"> <div class="view-format"> <label for="format">View Format</label> <div class="view-formats"> <a href="#" class="compact" @click.prevent="setFormat('compact')" :class="selectedViewingFormat === 'compact' ? 'active' : ''" >Compact</a > <a href="#" class="grid" @click.prevent="setFormat('grid')" :class="selectedViewingFormat === 'grid' ? 'active' : ''" >Grid</a > <a href="#" class="json" @click.prevent="setFormat('json')" :class="selectedViewingFormat === 'json' ? 'active' : ''" >JSON</a > </div> </div> <div class="actions"> <feature-flag name="workflowGraph" v-if="graphEnabled"> <a href="#" @click.prevent="toggleShowDagGraph()" >{{ graphView === GRAPH_VIEW_DAG ? 'hide' : 'show' }} graph</a > </feature-flag> <a href="#" @click.prevent="toggleShowTimeline()" class="show-timeline-btn" >{{ graphView === GRAPH_VIEW_TIMELINE ? 'hide' : 'show' }} timeline</a > <a class="export" href="#" @click.once.prevent="exportHistory" >Export</a > </div> </header> <Split class="split-panel" :class="graphView === GRAPH_VIEW_DAG ? 'dag-graph' : ''" :direction="splitDirection" @onDrag="onSplitResize" @onDragStart="enableSplitting" v-if="!showNoResults" ref="splitPanel" > <SplitArea class="timeline-split" :min-size="splitSizeMinSet[0]" :size="splitSizeSet[0]" > <feature-flag name="workflowGraph" v-if="graphEnabled && graphView === GRAPH_VIEW_DAG && events.length" > <WorkflowGraph :events="events" :isWorkflowRunning="isWorkflowRunning" :selected-event-id="eventId" class="tree-view" /> </feature-flag> <timeline :events="timelineEvents" :selected-event-id="eventId" v-if="graphView === GRAPH_VIEW_TIMELINE" /> </SplitArea> <SplitArea class="view-split" :min-size="splitSizeMinSet[1]" ref="viewSplit" :size="splitSizeSet[1]" > <section v-snapscroll class="results" ref="results"> <div class="table" v-if="selectedViewingFormat === 'grid' && showTable" :class="{ compact: compactDetails }" > <div class="thead"> <div class="th col-id">ID</div> <div class="th col-type"> <select-input background-color="rgb(248, 248, 249)" label="Type" min-width="150px" :options="eventTypes" :value="eventType" @change="setEventType" /> </div> <div class="th col-time"> Time </div> <div class="th col-elapsed-time"> Elapsed </div> <div class="th col-summary"> <a class="summary" :href="compactDetails ? null : '#'" @click.prevent="setCompactDetails(true)" >Summary</a > / <a class="details" :href="compactDetails ? '#' : null" @click.prevent="setCompactDetails(false)" >Full Details</a > </div> </div> <div class="spacer" /> <DynamicScroller key-field="eventId" :items="filteredEvents" :min-item-size="38" ref="scrollerGrid" style="height: 0px;" > <template v-slot="{ item, index, active }"> <DynamicScrollerItem class="scroller-item" :active="active" :data-active="active" :data-index="index" :item="item" > <div class="tr" :class="{ active: item.expanded, odd: index % 2 === 1 }" :data-event-type="item.eventType" :data-event-id="item.eventId" @click.prevent=" $router.replaceQueryParam('eventId', item.eventId) " > <div class="td col-id">{{ item.eventId }}</div> <div class="td col-type"> <highlight-toggle :is-highlighted="item.details.isHighlighted" :label="item.eventType" /> </div> <div class="td col-time"> {{ item.timeStampDisplay }} </div> <div class="td col-elapsed-time"> {{ item.timeElapsedDisplay }} </div> <div class="td col-summary"> <event-detail :event=" compactDetails && !item.expanded ? item.eventSummary : item.eventFullDetails " :is-highlight-enabled=" compactDetails && !item.expanded ? false : workflowHistoryEventHighlightListEnabled " :compact="compactDetails && !item.expanded" @onWorkflowHistoryEventParamToggle=" eventParam => onWorkflowHistoryEventParamToggle({ eventParam, eventType: item.eventType, }) " /> </div> </div> </DynamicScrollerItem> </template> </DynamicScroller> <footer-toolbar :cluster-name="clusterName" :pending-task-count="pendingTaskCount" /> </div> <prism class="json" language="json" v-if="selectedViewingFormat === 'json' && events.length < 90" >{{ JSON.stringify(events, null, 2) }}</prism > <pre class="json" v-if="selectedViewingFormat === 'json' && events.length >= 90" >{{ JSON.stringify(events, null, 2) }}</pre > <div class="compact-view" v-if="selectedViewingFormat === 'compact'"> <RecycleScroller class="scroller-compact" key-field="id" :items="timelineEvents" :item-size="70" ref="scrollerCompact" style="height: 0px;" > <template v-slot="{ item }"> <div :class=" `timeline-event ${item.className || ''} ${ item === selectedTimelineEvent ? ' vis-selected' : '' }` " @click.prevent="selectTimelineEvent(item)" > <span class="event-title">{{ item.content }}</span> <detail-list :compact="true" :is-highlight-enabled="false" :item="item.details" :title="item.content" /> </div> </template> </RecycleScroller> <div class="selected-event-detail" v-if="selectedTimelineEvent" :class="{ active: !!selectedTimelineEvent }" > <a href="#" class="close" @click.prevent="deselectEvent"></a> <span class="event-title" v-if="!selectedTimelineEvent.titleLink" >{{ selectedTimelineEvent.content }}</span > <router-link class="event-title" v-if="selectedTimelineEvent.titleLink" :to="selectedTimelineEvent.titleLink" >{{ selectedTimelineEvent.content }}</router-link > <detail-list class="timeline-details" :is-highlight-enabled="false" :item="selectedTimelineEvent.details" :title="selectedTimelineEvent.content" /> <div class="event-tabs"> <span>Events</span> <a href="#" :class="'event' + (eventId === eid ? ' active' : '')" v-for="eid in selectedTimelineEvent.eventIds" :key="eid" @click.prevent="$router.replaceQueryParam('eventId', eid)" :data-event-id="eid" > <highlight-toggle :is-highlighted=" events.find(event => event.eventId === eid).details .isHighlighted " :label=" events.find(event => event.eventId === eid).eventType " /> </a> </div> <detail-list class="event-detail" :is-highlight-enabled="workflowHistoryEventHighlightListEnabled" :item="selectedEventDetails" :title=" `${selectedTimelineEvent.content} - ${selectedEvent.eventType}` " @onWorkflowHistoryEventParamToggle=" eventParam => onWorkflowHistoryEventParamToggle({ eventParam, eventType: selectedEvent.eventType, }) " /> </div> </div> </section> </SplitArea> </Split> <span class="no-results" v-if="showNoResults">No Results</span> </section> </template> <style lang="stylus"> @require '../../styles/definitions.styl'; section.history { brdr = 1px solid uber-black-60; display: flex; flex-direction: column; flex: 1 1 auto; header.controls { display: flex; flex-wrap: wrap; justify-content: space-between; padding: inline-spacing-large; flex: 0 0 auto; .field { flex: 1 1 auto; } & > div { display: flex; align-items: center; & > * { margin: inline-spacing-small; } } a { action-button(); } } .view-formats { display: flex; a { flex: 0 0 auto; margin: 0; text-transform: none; &.active { background-color: darken(uber-blue, 20%); } } } paged-grid(); &:not(.has-results) header .actions { display: none; } &.loading.has-results { &::after { content: none; } } a.export { icon-download(); } .gutter.gutter-vertical { border-top: 1px solid uber-white-80; border-bottom: 1px solid uber-white-80; background-color: uber-white-20; } .split-panel.split.dag-graph { display: flex; flex-direction: row-reverse; } div.split-panel { .timeline-split { overflow: hidden; .feature-flag { height: 100%; } } .view-split { flex: 1; overflow: hidden; display: flex; position: relative; flex-direction: column; } } &:not(.split-enabled) div.split-panel { display: flex; flex-direction: column; flex: 1; .gutter { flex: 0 0 auto; } .timeline-split { flex: 0 0 auto; max-height: 350px; } } &.split-enabled div.split-panel { height: calc(100vh - 188px); } section pre { border: brdr; overflow: auto; } .table { .vue-recycle-scroller__slot, .vue-recycle-scroller__item-view, .scroller-item { display: flex; width: 100%; } .col-id { min-width: 50px; } .col-summary { flex: 1; } .col-time, .col-elapsed-time { min-width: 150px; } .col-type { min-width: 212px; } .thead { background-color: uber-white-10; box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.2); position: absolute; display: flex; top: 0; left: 0; z-index: 2; width: calc(100% - 10px); .th { color: rgb(0, 0, 0); display: inline-block; font-weight: 500; text-transform: uppercase; } & + .spacer { width: 100%; height: 60px; } } .tr { display: flex; flex: 1; border: 1px solid transparent; &.odd { background-color: #f8f8f9; } } .td, .th { flex-basis: auto; padding: 8px; } .th a:not([href]) { border-bottom: 1px solid black; } .td:nth-child(3), .td:nth-child(2) { one-liner-ellipsis(); } .tr[data-event-type*='Started'] .td:nth-child(2) { color: uber-blue-120; } .tr[data-event-type*='Failed'], .tr[data-event-type*='TimedOut'] { .td:nth-child(2), [data-prop='reason'], [data-prop='details'] { color: uber-orange; } } .tr[data-event-type*='Completed'] { .td:nth-child(2), [data-prop='result'] dt { color: uber-green; } } .tr.active { border-top: brdr; border-bottom: brdr; background-color: alpha(uber-blue, 5%); } pre { max-height: 15vh; } &.compact .tr:not(.active) { .td:nth-child(4) { overflow: hidden; } dl.details { max-width: 50vw; } } } .table.compact .tr:not(.active), .compact-view .timeline-event { dl.details { white-space: nowrap; & > div { display: inline-block; padding: 0; &:nth-child(2n) { background: none; } } dt, dd { display: inline-block; vertical-align: middle; margin: 0 0.5em; } dt { font-family: primary-font-family; font-weight: 200; text-transform: uppercase; } pre { display: inline-block; max-width: 40vw; one-liner-ellipsis(); } } } section.results { flex: 1; & > pre { margin: layout-spacing-small; padding: layout-spacing-small; } } wide-title-width = 400px; .hidden { display: none; } .compact-view { line-height: 1.5em; overflow-y: auto; .scroller-compact { padding: layout-spacing-small; padding-bottom: 0; } .event-title { padding: 4px; font-size: 16px; } pre { max-height: 15vh; } .timeline-event { border: 2px solid primary-color; cursor: pointer; padding: 6px; margin-bottom: layout-spacing-small; history-item-state-color(3%); dl.details dd { max-width: none; } @media (max-width: 1400px) { dl.details { display: none; } } @media (min-width: 1400px) { display: flex; align-items: center; .event-title { flex: 0 0 wide-title-width; } dl.details { flex: 1; align-items: center; overflow: hidden; pre { max-width: none; } } } } .selected-event-detail { position: absolute; width: 'calc(100vw - %s)' % (wide-title-width + 30px); height: 100%; top: 0; left: wide-title-width + 15px; overflow: auto; background-color: white; padding: layout-spacing-small; border-left: 1px solid uber-black-80; box-shadow: -5px 0 5px rgba(0, 0, 0, 0.25); .event-title { display: block; font-size: 1.4em; margin-bottom: 0.5em; } .event-tabs { // border-bottom 1px solid uber-black-60 margin-bottom: layout-spacing-small; margin-top: layout-spacing-small; a { display: inline-block; padding: inline-spacing-medium; font-family: monospace-font-family; border-bottom: 2px solid transparent; &.active { border-bottom-color: primary-color; } } & > span { font-weight: 200; text-transform: uppercase; font-size: 11px; } } dl.details { background-color: alpha(uber-white-80, 0.2); &.timeline-details dt, dd { padding: 4px; } } a.close { top: layout-spacing-small; } } } } </style>