client/containers/workflow-list/component.vue (573 lines of code) (raw):
<script>
// Copyright (c) 2017-2024 Uber Technologies Inc.
// Portions of the Software are attributed to Copyright (c) 2020-2024 Temporal 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 moment from 'moment';
import debounce from 'lodash-es/debounce';
import lowerCase from 'lodash-es/lowerCase';
import {
IS_CRON_LIST,
FILTER_MODE_ADVANCED,
STATE_ALL,
STATE_CLOSED,
STATE_OPEN,
STATUS_ALL,
STATUS_CLOSED,
STATUS_LIST,
STATUS_OPEN,
} from './constants';
import {
getCriteria,
getFormattedResults,
getMinStartDate,
isRangeValid,
isRouteRangeValid,
} from './helpers';
import {
ButtonFill,
DateRangePicker,
ErrorMessage,
FeatureFlag,
FlexGrid,
FlexGridItem,
SelectInput,
TextInput,
WorkflowGrid,
} from '~components';
import { delay, getEndTimeIsoString, getStartTimeIsoString } from '~helpers';
import { httpService } from '~services';
import { featureFlagService } from '~services';
export default {
name: 'workflow-list',
props: [
'clusterName',
'dateFormat',
'domain',
'fetchWorkflowListUrl',
'filterBy',
'filterMode',
'filterModeButtonEnabled',
'filterModeButtonLabel',
'isCron',
'isCronInputVisible',
'queryString',
'state',
'status',
'statusName',
'timeFormat',
'timezone',
'workflowId',
'workflowName',
],
data() {
return {
abortController: undefined,
isCronList: IS_CRON_LIST,
loading: false,
results: [],
error: undefined,
npt: undefined,
nptAlt: undefined,
statusList: STATUS_LIST,
maxRetentionDays: undefined,
defaultDateRange: undefined,
FILTER_MODE_ADVANCED: FILTER_MODE_ADVANCED,
};
},
async created() {
const defaultDateRange = await featureFlagService.getConfiguration({
name: 'defaultDateRange',
});
this.defaultDateRange = Number(defaultDateRange) || 30;
await this.fetchDomain();
this.fetchWorkflowList();
},
mounted() {
this.interval = setInterval(() => {
this.now = new Date();
}, 60 * 1000);
},
beforeDestroy() {
clearInterval(this.interval);
},
components: {
'button-fill': ButtonFill,
'date-range-picker': DateRangePicker,
'error-message': ErrorMessage,
'feature-flag': FeatureFlag,
'flex-grid': FlexGrid,
'flex-grid-item': FlexGridItem,
'select-input': SelectInput,
'text-input': TextInput,
'workflow-grid': WorkflowGrid,
},
computed: {
criteria() {
const {
endTime,
filterMode,
isCron,
queryString,
startTime,
statusName: status,
workflowId,
workflowName,
} = this;
return getCriteria({
endTime,
filterMode,
isCron,
queryString,
startTime,
status,
workflowId,
workflowName,
});
},
endTime() {
const { range, endTime } = this.$route.query;
if (this.range && this.range.endTime) {
return getEndTimeIsoString(null, this.range.endTime.toISOString());
}
return getEndTimeIsoString(range, endTime);
},
formattedResults() {
const { clusterName, dateFormat, results, timeFormat, timezone } = this;
return getFormattedResults({
clusterName,
dateFormat,
results,
timeFormat,
timezone,
});
},
minStartDate() {
return this.getMinStartDate();
},
range() {
const { defaultDateRange, maxRetentionDays, minStartDate, state } = this;
const query = this.$route.query || {};
if (defaultDateRange === undefined) {
return null;
}
if (state === STATE_CLOSED && maxRetentionDays === undefined) {
return null;
}
if (!this.isRouteRangeValid(minStartDate)) {
const defaultRange = [STATE_ALL, STATE_OPEN].includes(state)
? defaultDateRange
: maxRetentionDays;
const updatedQuery = this.setRange(
`last-${Math.min(defaultDateRange, defaultRange)}-days`
);
query.startTime = getStartTimeIsoString(
updatedQuery.range,
query.startTime
);
query.endTime = getEndTimeIsoString(updatedQuery.range, query.endTime);
}
return query.startTime && query.endTime
? {
startTime: moment(query.startTime),
endTime: moment(query.endTime),
}
: query.range;
},
startTime() {
const { range, startTime } = this.$route.query;
if (this.range && this.range.startTime) {
return getStartTimeIsoString(null, this.range.startTime.toISOString());
}
return getStartTimeIsoString(range, startTime);
},
noResultsMessageText() {
const { status, workflowId, workflowName, startTime, endTime } =
this.$route.query || {};
if ((status && status !== STATUS_ALL) || workflowId || workflowName) {
return `No workflows for the selected filters`;
}
if (typeof this.range === 'string') {
return `No workflows within ${lowerCase(this.range)}`;
}
if (startTime && endTime) {
return `No workflows within selected period`;
}
return 'No Results';
},
crossRegionProps() {
const { clusterName, domain } = this;
return { clusterName, domain };
},
},
methods: {
clearState() {
this.error = undefined;
this.loading = false;
this.npt = undefined;
this.nptAlt = undefined;
this.results = [];
},
async fetch(url, queryWithStatus) {
let workflows = [];
let nextPageToken = '';
if ([null, ''].includes(queryWithStatus.nextPageToken)) {
return { workflows, nextPageToken };
}
const includeStatus = ![STATUS_ALL, STATUS_OPEN, STATUS_CLOSED].includes(
queryWithStatus.status
);
const { status, ...queryWithoutStatus } = queryWithStatus;
const query = includeStatus ? queryWithStatus : queryWithoutStatus;
try {
if (this.abortController) {
this.abortController.abort();
await delay();
}
this.error = undefined;
this.loading = true;
this.abortController = new AbortController();
const { signal } = this.abortController;
const request = await httpService.get(url, {
query,
signal,
});
this.abortController = undefined;
workflows = request.executions;
nextPageToken = request.nextPageToken;
} catch (error) {
if (error.name === 'AbortError') {
return { status: 'aborted' };
}
this.error =
(error.json && error.json.message) || error.status || error.message;
return { status: 'error' };
} finally {
this.loading = false;
}
return { status: 'success', workflows, nextPageToken };
},
async fetchDomain() {
const { domain, now } = this;
this.loading = true;
try {
const domainInfo = await httpService.get(`/api/domains/${domain}`);
this.maxRetentionDays =
Number(
domainInfo.configuration.workflowExecutionRetentionPeriodInDays
) || 30;
this.loading = false;
const minStartDate = this.getMinStartDate();
if (!this.isRouteRangeValid(minStartDate)) {
const prevRange = localStorage.getItem(
`${domain}:workflows-time-range`
);
if (
prevRange &&
isRangeValid({ minStartDate, now, range: prevRange })
) {
this.setRange(prevRange);
} else {
this.setRange(`last-${Math.min(30, this.maxRetentionDays)}-days`);
}
}
} catch (error) {
this.error =
(error.json && error.json.message) || error.status || error.message;
} finally {
this.loading = false;
}
},
async fetchWorkflowList() {
if (!this.criteria || this.loading) {
return;
}
if (
this.filterMode === FILTER_MODE_ADVANCED &&
!this.criteria.queryString
) {
this.clearState();
return;
}
let workflows = [];
if (
this.state !== STATE_ALL ||
this.filterMode === FILTER_MODE_ADVANCED
) {
const query = { ...this.criteria, nextPageToken: this.npt };
if (query.queryString) {
query.queryString = decodeURI(query.queryString);
}
const { status, workflows: wfs, nextPageToken } = await this.fetch(
this.fetchWorkflowListUrl,
query
);
if (status !== 'success') {
return;
}
workflows = wfs;
this.npt = nextPageToken;
} else {
const { domain } = this;
const queryOpen = { ...this.criteria, nextPageToken: this.npt };
const queryClosed = { ...this.criteria, nextPageToken: this.nptAlt };
const {
status: openStatus,
workflows: wfsOpen,
nextPageToken: nptOpen,
} = await this.fetch(
`/api/domains/${domain}/workflows/open`,
queryOpen
);
if (openStatus !== 'success') {
return;
}
this.npt = nptOpen;
const {
status: closedStatus,
workflows: wfsClosed,
nextPageToken: nptClosed,
} = await this.fetch(
`/api/domains/${domain}/workflows/closed`,
queryClosed
);
if (closedStatus !== 'success') {
return;
}
this.nptAlt = nptClosed;
workflows = [...wfsOpen, ...wfsClosed];
}
this.results = [...this.results, ...workflows];
},
getMinStartDate() {
const { maxRetentionDays, now, statusName } = this;
return getMinStartDate({
maxRetentionDays,
now,
statusName,
});
},
isRouteRangeValid(minStartDate) {
const { now } = this;
const { endTime, range, startTime } = this.$route.query || {};
return isRouteRangeValid({
endTime,
minStartDate,
now,
range,
startTime,
});
},
refreshWorkflows: debounce(
function refreshWorkflows() {
this.clearState();
this.fetchWorkflowList();
},
typeof Mocha === 'undefined' ? 200 : 60,
{ maxWait: 1000 }
),
onFilterChange(event) {
const target = event.target || event.testTarget; // test hook since Event.target is readOnly and unsettable
const name = target.getAttribute('name');
const value = target.value;
this.$emit('onFilterChange', { [name]: value });
},
onIsCronChange(isCron) {
if (isCron) {
this.$emit('onFilterChange', { isCron: isCron.value });
}
},
onStatusChange(status) {
if (status) {
this.$emit('onFilterChange', { status: status.value });
}
},
onFilterModeClick() {
this.clearState();
this.$emit('onFilterModeClick');
},
onWorkflowGridScroll(startIndex, endIndex) {
if (!this.npt && !this.nptAlt) {
return;
}
return this.fetchWorkflowList();
},
setRange(range) {
const query = { ...this.$route.query };
if (range) {
if (typeof range === 'string') {
query.range = range;
delete query.startTime;
delete query.endTime;
localStorage.setItem(`${this.domain}:workflows-time-range`, range);
} else {
query.startTime = range.startTime.toISOString();
query.endTime = range.endTime.toISOString();
delete query.range;
}
} else {
delete query.range;
delete query.startTime;
delete query.endTime;
}
this.$router.replace({ query });
return query;
},
},
watch: {
criteria(newCriteria, oldCriteria) {
if (
newCriteria &&
oldCriteria &&
(newCriteria.startTime !== oldCriteria.startTime ||
newCriteria.endTime !== oldCriteria.endTime ||
newCriteria.isCron !== oldCriteria.isCron ||
newCriteria.queryString !== oldCriteria.queryString ||
newCriteria.status !== oldCriteria.status ||
newCriteria.workflowId !== oldCriteria.workflowId ||
newCriteria.workflowName !== oldCriteria.workflowName)
) {
this.refreshWorkflows();
}
},
async crossRegionProps() {
await this.fetchDomain();
this.refreshWorkflows();
},
},
};
</script>
<template>
<section class="workflow-list" :class="{ loading, ready: !loading }">
<header class="filters">
<template v-if="filterMode === FILTER_MODE_ADVANCED">
<flex-grid width="100%">
<flex-grid-item grow="1">
<text-input
label="Query"
type="search"
name="queryString"
:value="queryString"
@input="onFilterChange"
/>
</flex-grid-item>
<flex-grid-item>
<button-fill
@click="onFilterModeClick"
disabledLabel="Advanced visibility is not enabled"
:enabled="filterModeButtonEnabled"
:label="filterModeButtonLabel"
uppercase
/>
</flex-grid-item>
</flex-grid>
</template>
<template v-else>
<flex-grid width="100%">
<flex-grid-item grow="1">
<text-input
label="Workflow ID"
type="search"
name="workflowId"
:value="workflowId"
@input="onFilterChange"
/>
</flex-grid-item>
<flex-grid-item grow="1">
<text-input
label="Workflow Name"
type="search"
name="workflowName"
:value="workflowName"
@input="onFilterChange"
/>
</flex-grid-item>
<flex-grid-item grow="1" width="160px">
<select-input
data-cy="status-filter"
label="Status"
name="status"
:options="statusList"
:value="status"
@change="onStatusChange"
/>
</flex-grid-item>
<feature-flag
grow="1"
margin="5px"
name="workflowListIsCron"
v-if="isCronInputVisible"
width="115px"
>
<select-input
label="Cron"
name="isCron"
:options="isCronList"
:value="isCron"
@change="onIsCronChange"
/>
</feature-flag>
<flex-grid-item grow="1" width="105px">
<text-input
label="Filter by"
max-width="100%"
name="filterBy"
readonly
:value="filterBy"
/>
</flex-grid-item>
<flex-grid-item>
<date-range-picker
:date-range="range"
:max-days="maxRetentionDays"
:min-start-date="minStartDate"
@change="setRange"
/>
</flex-grid-item>
<flex-grid-item>
<button-fill
@click="onFilterModeClick"
disabledLabel="Advanced visibility is not enabled"
:enabled="filterModeButtonEnabled"
:label="filterModeButtonLabel"
uppercase
/>
</flex-grid-item>
</flex-grid>
</template>
</header>
<error-message :error="error" />
<workflow-grid
:workflows="formattedResults"
:noResultsText="noResultsMessageText"
:loading="loading"
@onScroll="onWorkflowGridScroll"
v-if="!error"
/>
</section>
</template>
<style lang="stylus">
@require "../../styles/definitions.styl"
section.workflow-list
display: flex;
flex-direction: column;
flex: 1;
&.loading section.results table
opacity 0.7
</style>