diff --git a/ui/src/app/cron-workflows/components/cron-workflow-filters/cron-workflow-filters.scss b/ui/src/app/cron-workflows/components/cron-workflow-filters/cron-workflow-filters.scss new file mode 100644 index 000000000000..f77489f4d762 --- /dev/null +++ b/ui/src/app/cron-workflows/components/cron-workflow-filters/cron-workflow-filters.scss @@ -0,0 +1,28 @@ +@import 'node_modules/argo-ui/src/styles/config'; + +.wf-filters-container { + overflow: visible; + position: relative; + border-radius: 5px; + box-shadow: 1px 1px 3px #8fa4b1; + padding: 0 1em 0.75em 1em; + margin: 12px 0; + background-color: white; +} + +.wf-filters-container p { + margin: 0; + margin-top: 1em; + color: #6d7f8b; + text-transform: uppercase; +} + +.wf-filters-container__title { + position: relative; + width: 100%; + max-width: 100%; + padding: 8px 0; + font-size: 15px; + background-color: transparent; + border: 0; +} \ No newline at end of file diff --git a/ui/src/app/cron-workflows/components/cron-workflow-filters/cron-workflow-filters.tsx b/ui/src/app/cron-workflows/components/cron-workflow-filters/cron-workflow-filters.tsx new file mode 100644 index 000000000000..d36c670ee5fd --- /dev/null +++ b/ui/src/app/cron-workflows/components/cron-workflow-filters/cron-workflow-filters.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import {useEffect, useState} from 'react'; +import * as models from '../../../../models'; +import {CheckboxFilter} from '../../../shared/components/checkbox-filter/checkbox-filter'; +import {NamespaceFilter} from '../../../shared/components/namespace-filter'; +import {TagsInput} from '../../../shared/components/tags-input/tags-input'; + +require('./cron-workflow-filters.scss'); + +interface WorkflowFilterProps { + cronWorkflows: models.WorkflowTemplate[]; + namespace: string; + labels: string[]; + states: string[]; + onChange: (namespace: string, labels: string[], states: string[]) => void; +} + +export const CronWorkflowFilters = ({cronWorkflows, namespace, labels, states, onChange}: WorkflowFilterProps) => { + const [labelSuggestion, setLabelSuggestion] = useState([]); + + useEffect(() => { + const suggestions = new Array(); + cronWorkflows + .filter(wf => wf.metadata.labels) + .forEach(wf => { + Object.keys(wf.metadata.labels).forEach(label => { + const value = wf.metadata.labels[label]; + const suggestedLabel = `${label}=${value}`; + if (!suggestions.some(v => v === suggestedLabel)) { + suggestions.push(`${label}=${value}`); + } + }); + }); + setLabelSuggestion(suggestions.sort((a, b) => a.localeCompare(b))); + }, [cronWorkflows]); + + return ( +
+
+
+

Namespace

+ { + onChange(ns, labels, states); + }} + /> +
+
+

Labels

+ { + onChange(namespace, tags, states); + }} + /> +
+
+

State

+ { + onChange(namespace, labels, selected); + }} + items={[ + {name: 'Running', count: 0}, + {name: 'Suspended', count: 1} + ]} + type='state' + /> +
+
+
+ ); +}; diff --git a/ui/src/app/cron-workflows/components/cron-workflow-list/cron-workflow-list.tsx b/ui/src/app/cron-workflows/components/cron-workflow-list/cron-workflow-list.tsx index b6724d8cdd27..804dd74a9e7b 100644 --- a/ui/src/app/cron-workflows/components/cron-workflow-list/cron-workflow-list.tsx +++ b/ui/src/app/cron-workflows/components/cron-workflow-list/cron-workflow-list.tsx @@ -1,162 +1,174 @@ import {Page, SlidingPanel, Ticker} from 'argo-ui'; import * as React from 'react'; +import {useContext, useEffect, useState} from 'react'; import {Link, RouteComponentProps} from 'react-router-dom'; -import * as models from '../../../../models'; +import {CronWorkflow} from '../../../../models'; import {uiUrl} from '../../../shared/base'; -import {BasePage} from '../../../shared/components/base-page'; import {ErrorNotice} from '../../../shared/components/error-notice'; import {ExampleManifests} from '../../../shared/components/example-manifests'; import {InfoIcon} from '../../../shared/components/fa-icons'; import {Loading} from '../../../shared/components/loading'; -import {NamespaceFilter} from '../../../shared/components/namespace-filter'; import {Timestamp} from '../../../shared/components/timestamp'; import {ZeroState} from '../../../shared/components/zero-state'; -import {Consumer} from '../../../shared/context'; +import {Context} from '../../../shared/context'; import {getNextScheduledTime} from '../../../shared/cron'; import {Footnote} from '../../../shared/footnote'; +import {historyUrl} from '../../../shared/history'; import {services} from '../../../shared/services'; +import {useQueryParams} from '../../../shared/use-query-params'; import {Utils} from '../../../shared/utils'; import {CronWorkflowCreator} from '../cron-workflow-creator'; +import {CronWorkflowFilters} from '../cron-workflow-filters/cron-workflow-filters'; import {PrettySchedule} from '../pretty-schedule'; require('./cron-workflow-list.scss'); -interface State { - namespace: string; - cronWorkflows?: models.CronWorkflow[]; - error?: Error; -} +const learnMore = Learn more; -export class CronWorkflowList extends BasePage, State> { - private get namespace() { - return this.state.namespace; - } +export const CronWorkflowList = ({match, location, history}: RouteComponentProps) => { + // boiler-plate + const queryParams = new URLSearchParams(location.search); + const {navigation} = useContext(Context); - private set namespace(namespace: string) { - this.fetchCronWorkflows(namespace); - } + // state for URL, query and label parameters + const [namespace, setNamespace] = useState(Utils.getNamespace(match.params.namespace) || ''); + const [sidePanel, setSidePanel] = useState(queryParams.get('sidePanel') === 'true'); + const [labels, setLabels] = useState([]); + const [states, setStates] = useState([]); - private get sidePanel() { - return this.queryParam('sidePanel'); - } + useEffect( + useQueryParams(history, p => { + setSidePanel(p.get('sidePanel') === 'true'); + }), + [history] + ); - private set sidePanel(sidePanel) { - this.setQueryParams({sidePanel}); - } + useEffect( + () => + history.push( + historyUrl('cron-workflows' + (Utils.managedNamespace ? '' : '/{namespace}'), { + namespace, + sidePanel + }) + ), + [namespace, sidePanel] + ); - constructor(props: any) { - super(props); - this.state = {namespace: Utils.getNamespace(this.props.match.params.namespace) || ''}; - } + // internal state + const [error, setError] = useState(); + const [cronWorkflows, setCronWorkflows] = useState(); - public componentDidMount(): void { - this.fetchCronWorkflows(this.state.namespace); - } - - public render() { - return ( - - {ctx => ( - (this.sidePanel = 'new') - } - ] - }, - tools: [ (this.namespace = namespace)} />] - }}> - {this.renderCronWorkflows()} - (this.sidePanel = null)}> - ctx.navigation.goto(uiUrl('cron-workflows/' + cronWorkflow.metadata.namespace + '/' + cronWorkflow.metadata.name))} - /> - - - )} - - ); - } - - private saveHistory() { - const newNamespace = Utils.managedNamespace ? '' : this.state.namespace; - this.url = uiUrl('cron-workflows' + (newNamespace ? '/' + newNamespace : '')); - Utils.currentNamespace = this.state.namespace; - } - - private fetchCronWorkflows(namespace: string): void { + useEffect(() => { services.cronWorkflows - .list(namespace) - .then(cronWorkflows => this.setState({error: null, namespace, cronWorkflows}, this.saveHistory)) - .catch(error => this.setState({error})); - } + .list(namespace, labels) + .then(l => { + if (states.length === 1) { + if (states.includes('Suspended')) { + return l.filter(el => el.spec.suspend === true); + } else { + return l.filter(el => el.spec.suspend !== true); + } + } + return l; + }) + .then(setCronWorkflows) + .then(() => setError(null)) + .catch(setError); + }, [namespace, labels, states]); - private renderCronWorkflows() { - if (this.state.error) { - return ; - } - if (!this.state.cronWorkflows) { - return ; - } - const learnMore = Learn more; - if (this.state.cronWorkflows.length === 0) { - return ( - -

You can create new cron workflows here or using the CLI.

-

- . {learnMore}. -

-
- ); - } - return ( - <> -
-
-
-
NAME
-
NAMESPACE
-
SCHEDULE
-
-
CREATED
-
NEXT RUN
+ return ( + setSidePanel(true) + } + ] + } + }}> +
+
+
+ { + setNamespace(namespaceValue); + setLabels(labelsValue); + setStates(stateValue); + }} + />
- {this.state.cronWorkflows.map(w => ( - -
{w.spec.suspend ? : }
-
{w.metadata.name}
-
{w.metadata.namespace}
-
{w.spec.schedule}
-
- -
-
- -
-
- {w.spec.suspend ? '' : {() => }} +
+
+ + {!cronWorkflows ? ( + + ) : cronWorkflows.length === 0 ? ( + +

You can create new cron workflows here or using the CLI.

+

+ . {learnMore}. +

+
+ ) : ( + <> +
+
+
+
NAME
+
NAMESPACE
+
SCHEDULE
+
+
CREATED
+
NEXT RUN
+
+ {cronWorkflows.map(w => ( + +
{w.spec.suspend ? : }
+
{w.metadata.name}
+
{w.metadata.namespace}
+
{w.spec.schedule}
+
+ +
+
+ +
+
+ {w.spec.suspend ? ( + '' + ) : ( + {() => } + )} +
+ + ))}
- - ))} + + Cron workflows are workflows that run on a preset schedule. Next scheduled run assumes workflow-controller is in UTC.{' '} + . {learnMore}. + + + )}
- - Cron workflows are workflows that run on a preset schedule. Next scheduled run assumes workflow-controller is in UTC. .{' '} - {learnMore}. - - - ); - } -} +
+ setSidePanel(false)}> + navigation.goto(uiUrl(`cron-workflows/${wf.metadata.namespace}/${wf.metadata.name}`))} /> + + + ); +}; diff --git a/ui/src/app/shared/services/archived-workflows-service.ts b/ui/src/app/shared/services/archived-workflows-service.ts index 48563dd9b6cb..3edd4b76f3bd 100644 --- a/ui/src/app/shared/services/archived-workflows-service.ts +++ b/ui/src/app/shared/services/archived-workflows-service.ts @@ -1,11 +1,11 @@ import * as models from '../../../models'; import {Pagination} from '../pagination'; +import {Utils} from '../utils'; import requests from './requests'; - export class ArchivedWorkflowsService { public list(namespace: string, name: string, namePrefix: string, phases: string[], labels: string[], minStartedAt: Date, maxStartedAt: Date, pagination: Pagination) { return requests - .get(`api/v1/archived-workflows?${this.queryParams({namespace, name, namePrefix, phases, labels, minStartedAt, maxStartedAt, pagination}).join('&')}`) + .get(`api/v1/archived-workflows?${Utils.queryParams({namespace, name, namePrefix, phases, labels, minStartedAt, maxStartedAt, pagination}).join('&')}`) .then(res => res.body as models.WorkflowList); } @@ -24,69 +24,4 @@ export class ArchivedWorkflowsService { public listLabelValues(key: string) { return requests.get(`api/v1/archived-workflows-label-values?listOptions.labelSelector=${key}`).then(res => res.body as models.Labels); } - - private queryParams(filter: { - namespace?: string; - name?: string; - namePrefix?: string; - phases?: Array; - labels?: Array; - minStartedAt?: Date; - maxStartedAt?: Date; - pagination: Pagination; - }) { - const queryParams: string[] = []; - const fieldSelector = this.fieldSelectorParams(filter.namespace, filter.name, filter.minStartedAt, filter.maxStartedAt); - if (fieldSelector.length > 0) { - queryParams.push(`listOptions.fieldSelector=${fieldSelector}`); - } - const labelSelector = this.labelSelectorParams(filter.phases, filter.labels); - if (labelSelector.length > 0) { - queryParams.push(`listOptions.labelSelector=${labelSelector}`); - } - if (filter.pagination.offset) { - queryParams.push(`listOptions.continue=${filter.pagination.offset}`); - } - if (filter.pagination.limit) { - queryParams.push(`listOptions.limit=${filter.pagination.limit}`); - } - if (filter.namePrefix) { - queryParams.push(`namePrefix=${filter.namePrefix}`); - } - return queryParams; - } - - private fieldSelectorParams(namespace: string, name: string, minStartedAt: Date, maxStartedAt: Date) { - let fieldSelector = ''; - if (namespace) { - fieldSelector += 'metadata.namespace=' + namespace + ','; - } - if (name) { - fieldSelector += 'metadata.name=' + name + ','; - } - if (minStartedAt) { - fieldSelector += 'spec.startedAt>' + minStartedAt.toISOString() + ','; - } - if (maxStartedAt) { - fieldSelector += 'spec.startedAt<' + maxStartedAt.toISOString() + ','; - } - if (fieldSelector.endsWith(',')) { - fieldSelector = fieldSelector.substr(0, fieldSelector.length - 1); - } - return fieldSelector; - } - - private labelSelectorParams(phases?: Array, labels?: Array) { - let labelSelector = ''; - if (phases && phases.length > 0) { - labelSelector = `workflows.argoproj.io/phase in (${phases.join(',')})`; - } - if (labels && labels.length > 0) { - if (labelSelector.length > 0) { - labelSelector += ','; - } - labelSelector += labels.join(','); - } - return labelSelector; - } } diff --git a/ui/src/app/shared/services/cron-workflow-service.ts b/ui/src/app/shared/services/cron-workflow-service.ts index 874b5f8e6c34..406310c9528b 100644 --- a/ui/src/app/shared/services/cron-workflow-service.ts +++ b/ui/src/app/shared/services/cron-workflow-service.ts @@ -1,4 +1,5 @@ import {CronWorkflow, CronWorkflowList} from '../../../models'; +import {Utils} from '../utils'; import requests from './requests'; export class CronWorkflowService { @@ -9,9 +10,9 @@ export class CronWorkflowService { .then(res => res.body as CronWorkflow); } - public list(namespace: string) { + public list(namespace: string, labels: string[] = []) { return requests - .get(`api/v1/cron-workflows/${namespace}`) + .get(`api/v1/cron-workflows/${namespace}?${Utils.queryParams({labels}).join('&')}`) .then(res => res.body as CronWorkflowList) .then(list => list.items || []); } diff --git a/ui/src/app/shared/services/workflow-template-service.ts b/ui/src/app/shared/services/workflow-template-service.ts index e021a88c1756..d2852c052631 100644 --- a/ui/src/app/shared/services/workflow-template-service.ts +++ b/ui/src/app/shared/services/workflow-template-service.ts @@ -1,4 +1,5 @@ import * as models from '../../../models'; +import {Utils} from '../utils'; import requests from './requests'; export class WorkflowTemplateService { @@ -11,7 +12,7 @@ export class WorkflowTemplateService { public list(namespace: string, labels: string[]) { return requests - .get(`api/v1/workflow-templates/${namespace}?${this.queryParams({labels}).join('&')}`) + .get(`api/v1/workflow-templates/${namespace}?${Utils.queryParams({labels}).join('&')}`) .then(res => res.body as models.WorkflowTemplateList) .then(list => list.items || []); } @@ -30,25 +31,4 @@ export class WorkflowTemplateService { public delete(name: string, namespace: string) { return requests.delete(`api/v1/workflow-templates/${namespace}/${name}`); } - - private queryParams(filter: {labels?: Array}) { - const queryParams: string[] = []; - const labelSelector = this.labelSelectorParams(filter.labels); - if (labelSelector.length > 0) { - queryParams.push(`listOptions.labelSelector=${labelSelector}`); - } - - return queryParams; - } - - private labelSelectorParams(labels?: Array) { - let labelSelector = ''; - if (labels && labels.length > 0) { - if (labelSelector.length > 0) { - labelSelector += ','; - } - labelSelector += labels.join(','); - } - return labelSelector; - } } diff --git a/ui/src/app/shared/services/workflows-service.ts b/ui/src/app/shared/services/workflows-service.ts index e00a394b7a62..679a7fe583f6 100644 --- a/ui/src/app/shared/services/workflows-service.ts +++ b/ui/src/app/shared/services/workflows-service.ts @@ -3,6 +3,7 @@ import * as models from '../../../models'; import {Event, LogEntry, NodeStatus, Workflow, WorkflowList, WorkflowPhase} from '../../../models'; import {SubmitOpts} from '../../../models/submit-opts'; import {Pagination} from '../pagination'; +import {Utils} from '../utils'; import requests from './requests'; import {WorkflowDeleteResponse} from './responses'; @@ -39,15 +40,7 @@ export class WorkflowsService { 'items.spec.suspend' ] ) { - const params = this.queryParams({phases, labels}); - if (pagination) { - if (pagination.offset) { - params.push(`listOptions.continue=${pagination.offset}`); - } - if (pagination.limit) { - params.push(`listOptions.limit=${pagination.limit}`); - } - } + const params = Utils.queryParams({phases, labels, pagination}); params.push(`fields=${fields.join(',')}`); return requests.get(`api/v1/workflows/${namespace}?${params.join('&')}`).then(res => res.body as WorkflowList); } @@ -63,7 +56,7 @@ export class WorkflowsService { labels?: Array; resourceVersion?: string; }): Observable> { - const url = `api/v1/workflow-events/${filter.namespace || ''}?${this.queryParams(filter).join('&')}`; + const url = `api/v1/workflow-events/${filter.namespace || ''}?${Utils.queryParams(filter).join('&')}`; return requests.loadEventSource(url).map(data => data && (JSON.parse(data).result as models.kubernetes.WatchEvent)); } @@ -78,7 +71,7 @@ export class WorkflowsService { labels?: Array; resourceVersion?: string; }): Observable> { - const params = this.queryParams(filter); + const params = Utils.queryParams(filter); const fields = [ 'result.object.metadata.name', 'result.object.metadata.namespace', @@ -233,33 +226,4 @@ export class WorkflowsService { return node.outputs.artifacts.findIndex(a => a.name === `${container}-logs`) !== -1; } - - private queryParams(filter: {namespace?: string; name?: string; phases?: Array; labels?: Array; resourceVersion?: string}) { - const queryParams: string[] = []; - if (filter.name) { - queryParams.push(`listOptions.fieldSelector=metadata.name=${filter.name}`); - } - const labelSelector = this.labelSelectorParams(filter.phases, filter.labels); - if (labelSelector.length > 0) { - queryParams.push(`listOptions.labelSelector=${labelSelector}`); - } - if (filter.resourceVersion) { - queryParams.push(`listOptions.resourceVersion=${filter.resourceVersion}`); - } - return queryParams; - } - - private labelSelectorParams(phases?: Array, labels?: Array) { - let labelSelector = ''; - if (phases && phases.length > 0) { - labelSelector = `workflows.argoproj.io/phase in (${phases.join(',')})`; - } - if (labels && labels.length > 0) { - if (labelSelector.length > 0) { - labelSelector += ','; - } - labelSelector += labels.join(','); - } - return labelSelector; - } } diff --git a/ui/src/app/shared/utils.ts b/ui/src/app/shared/utils.ts index 0af48a07e738..e4ba2d6d165e 100644 --- a/ui/src/app/shared/utils.ts +++ b/ui/src/app/shared/utils.ts @@ -1,6 +1,7 @@ import {Observable} from 'rxjs'; import * as models from '../../models'; import {NODE_PHASE} from '../../models'; +import {Pagination} from './pagination'; const managedNamespaceKey = 'managedNamespace'; const currentNamespaceKey = 'current_namespace'; @@ -118,5 +119,76 @@ export const Utils = { // return a namespace, never return null/undefined, defaults to "default" getNamespaceWithDefault(namespace: string) { return this.managedNamespace || namespace || this.currentNamespace || 'default'; + }, + + queryParams(filter: { + namespace?: string; + name?: string; + namePrefix?: string; + phases?: Array; + labels?: Array; + minStartedAt?: Date; + maxStartedAt?: Date; + pagination?: Pagination; + resourceVersion?: string; + }) { + const queryParams: string[] = []; + const fieldSelector = this.fieldSelectorParams(filter.namespace, filter.name, filter.minStartedAt, filter.maxStartedAt); + if (fieldSelector.length > 0) { + queryParams.push(`listOptions.fieldSelector=${fieldSelector}`); + } + const labelSelector = this.labelSelectorParams(filter.phases, filter.labels); + if (labelSelector.length > 0) { + queryParams.push(`listOptions.labelSelector=${labelSelector}`); + } + if (filter.pagination) { + if (filter.pagination.offset) { + queryParams.push(`listOptions.continue=${filter.pagination.offset}`); + } + if (filter.pagination.limit) { + queryParams.push(`listOptions.limit=${filter.pagination.limit}`); + } + } + if (filter.namePrefix) { + queryParams.push(`namePrefix=${filter.namePrefix}`); + } + if (filter.resourceVersion) { + queryParams.push(`listOptions.resourceVersion=${filter.resourceVersion}`); + } + return queryParams; + }, + + fieldSelectorParams(namespace?: string, name?: string, minStartedAt?: Date, maxStartedAt?: Date) { + let fieldSelector = ''; + if (namespace) { + fieldSelector += 'metadata.namespace=' + namespace + ','; + } + if (name) { + fieldSelector += 'metadata.name=' + name + ','; + } + if (minStartedAt) { + fieldSelector += 'spec.startedAt>' + minStartedAt.toISOString() + ','; + } + if (maxStartedAt) { + fieldSelector += 'spec.startedAt<' + maxStartedAt.toISOString() + ','; + } + if (fieldSelector.endsWith(',')) { + fieldSelector = fieldSelector.substr(0, fieldSelector.length - 1); + } + return fieldSelector; + }, + + labelSelectorParams(phases?: Array, labels?: Array) { + let labelSelector = ''; + if (phases && phases.length > 0) { + labelSelector = `workflows.argoproj.io/phase in (${phases.join(',')})`; + } + if (labels && labels.length > 0) { + if (labelSelector.length > 0) { + labelSelector += ','; + } + labelSelector += labels.join(','); + } + return labelSelector; } };