diff --git a/frontend/src/actions/ilabActions.js b/frontend/src/actions/ilabActions.js index 194a19b..9cce0be 100644 --- a/frontend/src/actions/ilabActions.js +++ b/frontend/src/actions/ilabActions.js @@ -1,11 +1,16 @@ import * as API_ROUTES from "@/utils/apiConstants"; import * as TYPES from "./types.js"; +import { showFailureToast } from "@/actions/toastActions"; import API from "@/utils/axiosInstance"; import { appendQueryString } from "@/utils/helper"; import { cloneDeep } from "lodash"; -import { showFailureToast } from "@/actions/toastActions"; +/** + * Fetch and store InstructLab jobs based on configured filters. + * + * @param {boolean} [shouldStartFresh=false] + */ export const fetchILabJobs = (shouldStartFresh = false) => async (dispatch, getState) => { @@ -53,7 +58,14 @@ export const fetchILabJobs = dispatch(showFailureToast()); } dispatch({ type: TYPES.COMPLETED }); - }; + }; + +/** + * Isolate the current page of cached jobs. + * + * @param {number} startIdx + * @param {number} endIdx + */ export const sliceIlabTableRows = (startIdx, endIdx) => (dispatch, getState) => { const results = [...getState().ilab.results]; @@ -63,6 +75,16 @@ export const sliceIlabTableRows = payload: results.slice(startIdx, endIdx), }); }; + + +/** + * Store the start & end date filters in redux and as URL + * query parameters for page reload. + * + * @param {string} start_date + * @param {string} end_date + * @param {React NavigateFunction} navigate + */ export const setIlabDateFilter = (start_date, end_date, navigate) => (dispatch, getState) => { const appliedFilters = getState().ilab.appliedFilters; @@ -78,6 +100,24 @@ export const setIlabDateFilter = appendQueryString({ ...appliedFilters, start_date, end_date }, navigate); }; +/** + * Fetch the set of possible InstructLab run filters and store them. + */ +export const fetchIlabFilters = () => async (dispatch, getState) => { + try { + const response = await API.get(`/api/v1/ilab/runs/filters`); + dispatch({ type: TYPES.SET_ILAB_RUN_FILTERS, payload: response.data }); + } catch (error) { + console.error(error); + dispatch(showFailureToast()); + } +}; + +/** + * Fetch the recorded metrics for a specific run and store them. + * + * @param {string} uid of a run + */ export const fetchMetricsInfo = (uid) => async (dispatch, getState) => { try { if (getState().ilab.metrics?.find((i) => i.uid == uid)) { @@ -104,6 +144,11 @@ export const fetchMetricsInfo = (uid) => async (dispatch, getState) => { dispatch({ type: TYPES.COMPLETED }); }; +/** + * Fetch the recording periods for a specific run and store them. + * + * @param {string} uid of a run + */ export const fetchPeriods = (uid) => async (dispatch, getState) => { try { if (getState().ilab.periods?.find((i) => i.uid == uid)) { @@ -128,57 +173,139 @@ export const fetchPeriods = (uid) => async (dispatch, getState) => { dispatch({ type: TYPES.COMPLETED }); }; -export const fetchSummaryData = (uid) => async (dispatch, getState) => { +/** + * Construct a metric title based on the metric template store. + * + * @param {string} uid of a run + * @returns {string} metric title based on the template + */ +const makeTitle = (run, period, metric, template) => { + if (!template) { + return null; + } try { - const periods = getState().ilab.periods.find((i) => i.uid == uid); - const metrics = getState().ilab.metrics_selected; - const avail_metrics = getState().ilab.metrics; - dispatch({ type: TYPES.SET_ILAB_SUMMARY_LOADING }); - let summaries = []; - periods?.periods?.forEach((p) => { - if (p.is_primary) { - summaries.push({ - run: uid, - metric: p.primary_metric, - periods: [p.id], - }); - } - if (metrics) { - metrics.forEach((metric) => { - if ( - avail_metrics.find((m) => m.uid == uid)?.metrics?.includes(metric) - ) { - summaries.push({ - run: uid, - metric, - aggregate: true, - periods: [p.id], - }); + const chip = /<((?
\w+):)?(?\w+)>/; + const params = run?.iterations?.find( + (i) => i.iteration === period.iteration + )?.params; + let title = ""; + for (const t of template) { + const ctx = chip.exec(t); + if (ctx !== null) { + const section = ctx.groups.section; + const name = ctx.groups.name; + if (!section) { + if (name === "metric") { + title += metric; + } else if (name == "iteration") { + title += period.iteration; + } else if (name === "period") { + title += period.name; } - }); + } else if (section === "run") { + title += run?.[name] || t; + } else if (section === "param") { + title += params?.[name] || t; + } else if (section === "tag") { + title += run.tags?.[name] || t; + } else { + title += ``; + } + } else { + title += t; } - }); - const response = await API.post( - `/api/v1/ilab/runs/multisummary`, - summaries - ); - if (response.status === 200) { - dispatch({ - type: TYPES.SET_ILAB_SUMMARY_DATA, - payload: { uid, data: response.data }, - }); } - } catch (error) { - console.error( - `ERROR (${error?.response?.status}): ${JSON.stringify( - error?.response?.data - )}` - ); - dispatch(showFailureToast()); + return title; + } catch (e) { + console.error(e); + throw e; } - dispatch({ type: TYPES.SET_ILAB_SUMMARY_COMPLETE }); }; +/** + * Fetch and store metric statistics for a run. + * + * This will iterate through all defined recording periods for the run, + * generating for each a request for statistics for each specified metric + * within the period's time range. In addition, each "primary period" will + * include a request for the period's "primary metric". + * + * To maximize store effectiveness, we store data for each run separately, + * and combine data from multiple runs in the comparison table as needed + * rather than combining runs in a single request. + * + * NOTE: we don't try to avoid a duplicate fetch operation for statistics + * because the API call depends on the set of metrics selected and not just + * the selected run. + * + * @param {string} uid of a run + * @param {boolean} [useTemplate=false] + */ +export const fetchSummaryData = + (uid, useTemplate = false) => + async (dispatch, getState) => { + try { + const run = getState().ilab.results.find((i) => i.id == uid); + const template = useTemplate ? getState().ilab.metricTemplate : null; + const periods = getState().ilab.periods.find((i) => i.uid == uid); + const metrics = getState().ilab.metrics_selected; + const avail_metrics = getState().ilab.metrics; + dispatch({ type: TYPES.SET_ILAB_SUMMARY_LOADING }); + let summaries = []; + periods?.periods?.forEach((p) => { + if (p.is_primary) { + summaries.push({ + run: uid, + metric: p.primary_metric, + periods: [p.id], + title: makeTitle(run, p, p.primary_metric, template), + }); + } + if (metrics) { + metrics.forEach((metric) => { + if ( + avail_metrics.find((m) => m.uid == uid)?.metrics?.includes(metric) + ) { + summaries.push({ + run: uid, + metric, + aggregate: true, + periods: [p.id], + title: makeTitle(run, p, metric, template), + }); + } + }); + } + }); + const response = await API.post( + `/api/v1/ilab/runs/multisummary`, + summaries + ); + if (response.status === 200) { + dispatch({ + type: TYPES.SET_ILAB_SUMMARY_DATA, + payload: { uid, data: response.data }, + }); + } + } catch (error) { + console.error( + `ERROR (${error?.response?.status}): ${JSON.stringify( + error?.response?.data + )}` + ); + dispatch(showFailureToast()); + } + dispatch({ type: TYPES.SET_ILAB_SUMMARY_COMPLETE }); + }; + +/** + * A helper to ensure that statistical summary data is available for a + * selected set of runs. In general we should expect that all periods have + * been fetched, but we make sure of that before fetching summary data for + * all specified runs. + * + * @param {Array[string]} run uids + */ export const handleSummaryData = (uids) => async (dispatch, getState) => { try { const periods = getState().ilab.periods; @@ -193,7 +320,7 @@ export const handleSummaryData = (uids) => async (dispatch, getState) => { ); await Promise.all( uids.map(async (uid) => { - await dispatch(fetchSummaryData(uid)); + await dispatch(fetchSummaryData(uid, true)); }) ); } catch (error) { @@ -202,6 +329,17 @@ export const handleSummaryData = (uids) => async (dispatch, getState) => { } }; +/** + * Fetch and store Plotly graph data for a specified run. + * + * This will iterate through all defined recording periods for the run, + * generating for each a graph request for each specified metric + * within the period's time range. In addition, each "primary period" will + * include a graph request for the period's "primary metric". + * + * @param {string} run uid + * @returns {(dispatch: any, getState: any) => any} + */ export const fetchGraphData = (uid) => async (dispatch, getState) => { try { const periods = getState().ilab.periods.find((i) => i.uid == uid); @@ -262,6 +400,13 @@ export const fetchGraphData = (uid) => async (dispatch, getState) => { dispatch({ type: TYPES.GRAPH_COMPLETED }); }; +/** + * A helper to ensure that graph data is available for a selected set of + * runs. In general we should expect that all periods have been fetched, but + * we make sure of that before fetching graph data for all specified runs. + * + * @param {Array[string]} run uids + */ export const handleMultiGraph = (uids) => async (dispatch, getState) => { try { const periods = getState().ilab.periods; @@ -287,6 +432,17 @@ export const handleMultiGraph = (uids) => async (dispatch, getState) => { dispatch(showFailureToast()); } }; + +/** + * Generate a single Plotly graph containing potentially multiple metrics from + * all periods of a set of runs. + * + * For each run, we iterate through the collection periods, generating a graph + * for each selected metric over that period's time range. For each "primary + * period" we also generate a graph for that period's "primary metric". + * + * @param {Array[string]} run uids + */ export const fetchMultiGraphData = (uids) => async (dispatch, getState) => { try { dispatch({ type: TYPES.LOADING }); @@ -294,9 +450,11 @@ export const fetchMultiGraphData = (uids) => async (dispatch, getState) => { const filterPeriods = periods.filter((item) => uids.includes(item.uid)); const get_metrics = getState().ilab.metrics_selected; const avail_metrics = getState().ilab.metrics; + const template = getState().ilab.metricTemplate; let graphs = []; uids.forEach(async (uid) => { + const run = getState().ilab.results.find((i) => i.id === uid); const periods = filterPeriods.find((i) => i.uid == uid); periods?.periods?.forEach((p) => { if (p.is_primary) { @@ -304,6 +462,7 @@ export const fetchMultiGraphData = (uids) => async (dispatch, getState) => { run: uid, metric: p.primary_metric, periods: [p.id], + title: makeTitle(run, p, p.primary_metric, template), }); } if (get_metrics) { @@ -316,6 +475,7 @@ export const fetchMultiGraphData = (uids) => async (dispatch, getState) => { metric, aggregate: true, periods: [p.id], + title: makeTitle(run, p, metric, template), }); } }); @@ -352,16 +512,32 @@ export const fetchMultiGraphData = (uids) => async (dispatch, getState) => { dispatch({ type: TYPES.COMPLETED }); }; +/** + * Store the current page number used to slice cached run data for display. + * + * @param {number} pageNo + */ export const setIlabPage = (pageNo) => ({ type: TYPES.SET_ILAB_PAGE, payload: pageNo, }); +/** + * Store an updated page size and page number. + * + * @param {number} page + * @param {number} perPage + */ export const setIlabPageOptions = (page, perPage) => ({ type: TYPES.SET_ILAB_PAGE_OPTIONS, payload: { page, perPage }, }); +/** + * Fetch and store a page of results if not already in the store. + * + * @param {number} newPage + */ export const checkIlabJobs = (newPage) => (dispatch, getState) => { const results = cloneDeep(getState().ilab.results); const { totalItems, perPage } = getState().ilab; @@ -378,6 +554,12 @@ export const checkIlabJobs = (newPage) => (dispatch, getState) => { } }; +/** + * Add a new metric to the selected list if not present, or remove it if it + * was previously present. + * + * @param {string} metric + */ export const toggleSelectedMetric = (metric) => (dispatch, getState) => { let metrics_selected = getState().ilab.metrics_selected; if (metrics_selected.includes(metric)) { @@ -391,6 +573,9 @@ export const toggleSelectedMetric = (metric) => (dispatch, getState) => { }); }; +/** + * Reconcile pagination and accordion state. + */ export const tableReCalcValues = () => (dispatch, getState) => { const { page, perPage } = getState().ilab; @@ -400,15 +585,27 @@ export const tableReCalcValues = () => (dispatch, getState) => { dispatch(getMetaRowdId()); }; +/** + * Help to manage the set of accordion folds that are open. + */ export const getMetaRowdId = () => (dispatch, getState) => { const tableData = getState().ilab.tableData; const metaId = tableData.map((item) => `metadata-toggle-${item.id}`); dispatch(setMetaRowExpanded(metaId)); }; + +/** + * Toggle the state of the comparison view. + */ export const toggleComparisonSwitch = () => ({ type: TYPES.TOGGLE_COMPARISON_SWITCH, }); +/** + * Store the set of expanded rows. + * + * @param {Array[string]} expandedItems list of currently expanded runs + */ export const setMetaRowExpanded = (expandedItems) => ({ type: TYPES.SET_EXPANDED_METAROW, payload: expandedItems, diff --git a/frontend/src/actions/types.js b/frontend/src/actions/types.js index 70a6787..031e560 100644 --- a/frontend/src/actions/types.js +++ b/frontend/src/actions/types.js @@ -93,5 +93,7 @@ export const SET_ILAB_METRICS = "SET_ILAB_METRICS"; export const SET_ILAB_SELECTED_METRICS = "SET_ILAB_SELECTED_METRICS"; export const SET_ILAB_PERIODS = "SET_ILAB_PERIODS"; export const SET_ILAB_INIT_JOBS = "SET_ILAB_INIT_JOBS"; +export const SET_ILAB_RUN_FILTERS = "SET_ILAB_RUN_FILTERS"; +export const SET_ILAB_METRIC_TEMPLATE = "SET_ILAB_METRIC_TEMPLATE"; export const TOGGLE_COMPARISON_SWITCH = "TOGGLE_COMPARISON_SWITCH"; export const SET_EXPANDED_METAROW = "SET_EXPANDED_METAROW"; diff --git a/frontend/src/components/templates/ILab/IlabCompareComponent.jsx b/frontend/src/components/templates/ILab/IlabCompareComponent.jsx index e81b6f6..4d4cbc6 100644 --- a/frontend/src/components/templates/ILab/IlabCompareComponent.jsx +++ b/frontend/src/components/templates/ILab/IlabCompareComponent.jsx @@ -29,6 +29,7 @@ import { useState } from "react"; import ILabSummary from "./ILabSummary"; import ILabMetadata from "./ILabMetadata"; import MetricsSelect from "./MetricsDropdown"; +import MetricTitle from "./MetricTitle"; const IlabCompareComponent = () => { const { page, perPage, totalItems, tableData } = useSelector( @@ -119,6 +120,9 @@ const IlabCompareComponent = () => { + + + {isSummaryLoading ? (
diff --git a/frontend/src/components/templates/ILab/MetricTitle.jsx b/frontend/src/components/templates/ILab/MetricTitle.jsx new file mode 100644 index 0000000..244065b --- /dev/null +++ b/frontend/src/components/templates/ILab/MetricTitle.jsx @@ -0,0 +1,293 @@ +import React from "react"; +import { + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, + Menu, + MenuContent, + MenuList, + MenuItem, + Popper, + Divider, + Label, + LabelGroup, +} from "@patternfly/react-core"; +import { useDispatch, useSelector } from "react-redux"; +import { useState } from "react"; +import SearchIcon from "@patternfly/react-icons/dist/esm/icons/search-icon"; +import TimesIcon from "@patternfly/react-icons/dist/esm/icons/times-icon"; +import * as TYPES from "@/actions/types.js"; + +export const MetricTitle = () => { + const dispatch = useDispatch(); + const [inputValue, setInputValue] = useState(""); + const [menuIsOpen, setMenuIsOpen] = useState(false); + const [hint, setHint] = useState(""); + + /** auto-completing suggestion text items to be shown in the menu */ + let suggestionItems = ["", "", ""]; + const { runFilters, metricTemplate } = useSelector((state) => state.ilab); + for (const s of Object.keys(runFilters).sort()) { + if (runFilters[s]) { + for (const n of Object.keys(runFilters[s]).sort()) { + suggestionItems.push(`<${s}:${n}>`); + } + } + } + const [menuItems, setMenuItems] = useState([]); + + /** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */ + const menuRef = React.useRef(); + const textInputGroupRef = React.useRef(); + + /** callback for updating the inputValue state in this component so that the input can be controlled */ + const handleInputChange = (_event, value) => { + setInputValue(value); + }; + + /** callback for removing a chip from the chip selections */ + const deleteChip = (chipToDelete) => { + const newChips = metricTemplate.filter( + (chip) => !Object.is(chip, chipToDelete) + ); + dispatch({ type: TYPES.SET_ILAB_METRIC_TEMPLATE, payload: newChips }); + }; + + /** callback for clearing all selected chips and the text input */ + const clearChipsAndInput = () => { + dispatch({ type: TYPES.SET_ILAB_METRIC_TEMPLATE, payload: [] }); + setInputValue(""); + }; + + React.useEffect(() => { + /** in the menu only show items that include the text in the input */ + const filteredMenuItems = suggestionItems + .filter( + (item) => + !inputValue || + item.toLowerCase().includes(inputValue.toString().toLowerCase()) + ) + .map((currentValue, index) => ( + + {currentValue} + + )); + + /** in the menu show a disabled "no result" when all menu items are filtered out */ + if (filteredMenuItems.length === 0) { + const noResultItem = ( + + No results found + + ); + setMenuItems([noResultItem]); + setHint(""); + return; + } + + /** The hint is set whenever there is only one autocomplete option left. */ + if (filteredMenuItems.length === 1) { + const hint = filteredMenuItems[0].props.children; + if (hint.toLowerCase().indexOf(inputValue.toLowerCase())) { + // the match was found in a place other than the start, so typeahead wouldn't work right + setHint(""); + } else { + // use the input for the first part, otherwise case difference could make things look wrong + setHint(inputValue + hint.substr(inputValue.length)); + } + } else { + setHint(""); + } + + /** add a heading to the menu */ + const headingItem = ( + + Suggestions + + ); + + const divider = ; + + setMenuItems([headingItem, divider, ...filteredMenuItems]); + }, [inputValue]); + + /** add the given string as a chip in the chip group and clear the input */ + const addChip = (newChipText) => { + dispatch({ + type: TYPES.SET_ILAB_METRIC_TEMPLATE, + payload: [...metricTemplate, newChipText], + }); + setInputValue(""); + }; + + /** add the current input value as a chip */ + const handleEnter = () => { + if (inputValue.length) { + addChip(inputValue); + } + }; + + const handleTab = () => { + if (menuItems.length === 3) { + setInputValue(menuItems[2].props.children); + } + setMenuIsOpen(false); + }; + + /** close the menu when escape is hit */ + const handleEscape = () => { + setMenuIsOpen(false); + }; + + /** allow the user to focus on the menu and navigate using the arrow keys */ + const handleArrowKey = () => { + if (menuRef.current) { + const firstElement = menuRef.current.querySelector( + "li > button:not(:disabled)" + ); + firstElement && firstElement.focus(); + } + }; + + /** reopen the menu if it's closed and any un-designated keys are hit */ + const handleDefault = () => { + if (!menuIsOpen) { + setMenuIsOpen(true); + } + }; + + /** enable keyboard only usage while focused on the text input */ + const handleTextInputKeyDown = (event) => { + switch (event.key) { + case "Enter": + handleEnter(); + break; + case "Escape": + handleEscape(); + break; + case "Tab": + handleTab(); + break; + case "ArrowUp": + case "ArrowDown": + handleArrowKey(); + break; + default: + handleDefault(); + } + }; + + /** apply focus to the text input */ + const focusTextInput = () => { + textInputGroupRef.current.querySelector("input").focus(); + }; + + /** add the text of the selected item as a new chip */ + const onSelect = (event, _itemId) => { + const selectedText = event.target.innerText; + addChip(selectedText); + event.stopPropagation(); + focusTextInput(); + }; + + /** close the menu when a click occurs outside of the menu or text input group */ + const handleClick = (event) => { + if ( + menuRef.current && + !menuRef.current.contains(event.target) && + !textInputGroupRef.current.contains(event.target) + ) { + setMenuIsOpen(false); + } + }; + + /** enable keyboard only usage while focused on the menu */ + const handleMenuKeyDown = (event) => { + switch (event.key) { + case "Tab": + case "Escape": + event.preventDefault(); + focusTextInput(); + setMenuIsOpen(false); + break; + case "Enter": + case " ": + setTimeout(() => setMenuIsOpen(false), 0); + break; + } + }; + + /** show the search icon only when there are no chips to prevent the chips from being displayed behind the icon */ + const showSearchIcon = !metricTemplate.length; + + /** only show the clear button when there is something that can be cleared */ + const showClearButton = !!inputValue || !!metricTemplate.length; + + /** render the utilities component only when a component it contains is being rendered */ + const showUtilities = showClearButton; + + const inputGroup = ( +
+ + } + value={inputValue} + hint={hint} + onChange={handleInputChange} + onFocus={() => setMenuIsOpen(true)} + onKeyDown={handleTextInputKeyDown} + placeholder="template" + aria-label="Metric title template" + > + + {metricTemplate.map((currentChip) => ( + + ))} + + + {showUtilities && ( + + {showClearButton && ( +
+ ); + + const menu = ( +
+ + + {menuItems} + + +
+ ); + + return ( + textInputGroupRef.current} + isVisible={menuIsOpen} + onDocumentClick={handleClick} + /> + ); +}; +export default MetricTitle; diff --git a/frontend/src/components/templates/ILab/index.jsx b/frontend/src/components/templates/ILab/index.jsx index c5bfc61..4de6ee8 100644 --- a/frontend/src/components/templates/ILab/index.jsx +++ b/frontend/src/components/templates/ILab/index.jsx @@ -10,6 +10,7 @@ import { Tr, } from "@patternfly/react-table"; import { + fetchIlabFilters, fetchILabJobs, fetchGraphData, fetchMetricsInfo, @@ -64,6 +65,7 @@ const ILab = () => { }; useEffect(() => { + dispatch(fetchIlabFilters()); if (searchParams.size > 0) { // date filter is set apart const startDate = searchParams.get("start_date"); diff --git a/frontend/src/reducers/ilabReducer.js b/frontend/src/reducers/ilabReducer.js index 46d39df..655fae5 100644 --- a/frontend/src/reducers/ilabReducer.js +++ b/frontend/src/reducers/ilabReducer.js @@ -2,6 +2,8 @@ import * as TYPES from "@/actions/types"; const initialState = { results: [], + runFilters: {}, + metricTemplate: [], start_date: "", end_date: "", graphData: [], @@ -76,6 +78,10 @@ const ILabReducer = (state = initialState, action = {}) => { payload, ], }; + case TYPES.SET_ILAB_RUN_FILTERS: + return { ...state, runFilters: payload }; + case TYPES.SET_ILAB_METRIC_TEMPLATE: + return { ...state, metricTemplate: payload }; default: return state; } diff --git a/publish-containers.sh b/publish-containers.sh index ca97f8b..4a2dca9 100755 --- a/publish-containers.sh +++ b/publish-containers.sh @@ -17,23 +17,30 @@ REGISTRY=${REGISTRY:-images.paas.redhat.com} ACCOUNT=${ACCOUNT:-${USER}} REPO=${REPO:-cpt} -TAG=${TAG:-latest} SKIP_FRONTEND=${SKIP_FRONTEND:-0} SKIP_BACKEND=${SKIP_BACKEND:-0} -REPOSITORY=${REGISTRY}/${ACCOUNT}/${REPO} +REPOSITORY="${REGISTRY}/${ACCOUNT}/${REPO}" + +TAG=${TAG:-latest} +BRANCH=$(git symbolic-ref --short HEAD) +SHA1=$(git rev-parse --short=9 HEAD) +REVISION="${BRANCH}-${SHA1}" +TIMESTAMP=$(date +'%Y%m%d-%H%M%S') + # remove current images, if any podman rm -f front back -now=$(date +'%Y%m%d-%H%M%S') if [ "$SKIP_BACKEND" != 1 ] ;then podman build -f backend/backend.containerfile --tag backend podman push backend "${REPOSITORY}/backend:${TAG}" - podman push backend "${REPOSITORY}/backend:${now}" + podman push backend "${REPOSITORY}/backend:${TIMESTAMP}" + podman push backend "${REPOSITORY}/backend:${REVISION}" fi if [ "$SKIP_FRONTEND" != 1 ] ;then podman build -f frontend/frontend.containerfile --tag frontend podman push frontend "${REPOSITORY}/frontend:${TAG}" - podman push frontend "${REPOSITORY}/frontend:${now}" + podman push frontend "${REPOSITORY}/frontend:${TIMESTAMP}" + podman push frontend "${REPOSITORY}/frontend:${REVISION}" fi