diff --git a/app/src/lang/index.ts b/app/src/lang/index.ts index f4213d7623..1711e3f161 100644 --- a/app/src/lang/index.ts +++ b/app/src/lang/index.ts @@ -11,7 +11,7 @@ export const i18n = createI18n({ messages: { 'en-US': enUSBase, }, - dateTimeFormats: dateFormats, + datetimeFormats: dateFormats, silentTranslationWarn: true, }); diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml index f5797b2a15..7d99e8d36c 100644 --- a/app/src/lang/translations/en-US.yaml +++ b/app/src/lang/translations/en-US.yaml @@ -144,6 +144,7 @@ boolean: Boolean date: Date date_range: Date Range datetime: DateTime +precision: Precision decimal: Decimal float: Float integer: Integer diff --git a/app/src/panels/time-series/index.ts b/app/src/panels/time-series/index.ts index 04b73d1cf6..37a2d3e06a 100644 --- a/app/src/panels/time-series/index.ts +++ b/app/src/panels/time-series/index.ts @@ -160,6 +160,46 @@ export default definePanel({ default_value: 2, }, }, + { + field: 'precision', + type: 'string', + name: '$t:precision', + meta: { + interface: 'select-dropdown', + width: 'half', + options: { + choices: [ + { + text: 'Second', + value: 'second', + }, + { + text: 'Minute', + value: 'minute', + }, + { + text: 'Hour', + value: 'hour', + }, + { + text: 'Day', + value: 'day', + }, + { + text: 'Month', + value: 'month', + }, + { + text: 'Year', + value: 'year', + }, + ], + }, + }, + schema: { + default_value: 'hour', + }, + }, { field: 'color', name: '$t:color', diff --git a/app/src/panels/time-series/time-series.vue b/app/src/panels/time-series/time-series.vue index 2f9e4c7be5..0e8a23c25d 100644 --- a/app/src/panels/time-series/time-series.vue +++ b/app/src/panels/time-series/time-series.vue @@ -6,6 +6,8 @@ import { defineComponent, PropType, ref, watch, onMounted, onUnmounted } from 'vue'; import api from '@/api'; import ApexCharts from 'apexcharts'; +import { adjustDate } from '@/utils/adjust-date'; +import { useI18n } from 'vue-i18n'; type TimeSeriesOptions = { collection: string; @@ -15,6 +17,7 @@ type TimeSeriesOptions = { range: string; // 1 week, etc color: string; decimals: number; + precision: 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second'; }; export default defineComponent({ @@ -29,17 +32,130 @@ export default defineComponent({ }, }, setup(props) { + const { d } = useI18n(); + const metrics = ref[]>([]); const loading = ref(false); const error = ref(); const chartEl = ref(); const chart = ref(); - watch(() => props.options, fetchData, { deep: true }); + watch( + () => props.options, + () => { + fetchData(); + chart.value?.destroy(); + setupChart(); + }, + { deep: true } + ); fetchData(); - onMounted(() => { + onMounted(setupChart); + + onUnmounted(() => { + chart.value?.destroy(); + }); + + return { chartEl, metrics, loading, error }; + + async function fetchData() { + loading.value = true; + + try { + const results = await api.get(`/items/${props.options.collection}`, { + params: { + group: getGroups(), + aggregate: { + [props.options.function]: [props.options.valueField], + }, + filter: { + _and: [ + { + [props.options.dateField]: { + _gte: `$NOW(-${props.options.range || '1 week'})`, + }, + }, + { + [props.options.dateField]: { + _lte: `$NOW`, + }, + }, + ], + }, + limit: -1, + }, + }); + + metrics.value = results.data.data; + + chart.value?.updateSeries([ + { + name: props.options.collection, + data: metrics.value.map((metric) => ({ + x: toISO(metric), + y: Number( + Number(metric[`${props.options.valueField}_${props.options.function}`]).toFixed( + props.options.decimals ?? 0 + ) + ), + })), + }, + ]); + } catch (err) { + error.value = err; + } finally { + loading.value = false; + } + + function toISO(metric: Record) { + const year = metric[`${props.options.dateField}_year`]; + const month = padZero(metric[`${props.options.dateField}_month`] ?? 1); + const day = padZero(metric[`${props.options.dateField}_day`] ?? 1); + const hour = padZero(metric[`${props.options.dateField}_hour`] ?? 0); + const minute = padZero(metric[`${props.options.dateField}_minute`] ?? 0); + const second = padZero(metric[`${props.options.dateField}_second`] ?? 0); + + return `${year}-${month}-${day}T${hour}:${minute}:${second}`; + + function padZero(value: number) { + return String(value).padStart(2, '0'); + } + } + + function getGroups() { + let groups: string[] = []; + + switch (props.options.precision || 'hour') { + case 'year': + groups = ['year']; + break; + case 'month': + groups = ['year', 'month']; + break; + case 'day': + groups = ['year', 'month', 'day']; + break; + case 'hour': + groups = ['year', 'month', 'day', 'hour']; + break; + case 'minute': + groups = ['year', 'month', 'day', 'hour', 'minute']; + break; + case 'second': + groups = ['year', 'month', 'day', 'hour', 'minute', 'second']; + break; + default: + groups = ['year', 'month', 'day', 'hour']; + break; + } + + return groups.map((datePart) => `${datePart}(${props.options.dateField})`); + } + } + + function setupChart() { chart.value = new ApexCharts(chartEl.value, { colors: [props.options.color ? props.options.color : 'var(--primary)'], chart: { @@ -82,6 +198,12 @@ export default defineComponent({ ], }, }, + grid: { + padding: { + top: -20, + bottom: 8, + }, + }, dataLabels: { enabled: false, }, @@ -89,6 +211,12 @@ export default defineComponent({ marker: { show: false, }, + x: { + show: true, + formatter(date: number) { + return d(new Date(date), 'long'); + }, + }, }, xaxis: { type: 'datetime', @@ -101,82 +229,12 @@ export default defineComponent({ axisBorder: { show: false, }, + range: new Date().getTime() - adjustDate(new Date(), `-${props.options.range}`)!.getTime(), + max: new Date().getTime(), }, }); chart.value.render(); - }); - - onUnmounted(() => { - chart.value?.destroy(); - }); - - return { chartEl, metrics, loading, error }; - - async function fetchData() { - loading.value = true; - - try { - const results = await api.get(`/items/${props.options.collection}`, { - params: { - group: [ - `year(${props.options.dateField})`, - `month(${props.options.dateField})`, - `day(${props.options.dateField})`, - `hour(${props.options.dateField})`, - ], - aggregate: { - [props.options.function]: [props.options.valueField], - }, - filter: { - _and: [ - { - [props.options.dateField]: { - _gte: `$NOW(-${props.options.range || '1 week'})`, - }, - }, - { - [props.options.dateField]: { - _lte: `$NOW`, - }, - }, - ], - }, - }, - }); - - metrics.value = results.data.data; - - chart.value?.updateOptions({ - xaxis: { - categories: metrics.value.map((metric) => { - const year = metric[`${props.options.dateField}_year`]; - const month = metric[`${props.options.dateField}_month`]; - const day = metric[`${props.options.dateField}_day`]; - const hour = metric[`${props.options.dateField}_hour`]; - - return `${year}-${month}-${day}T${hour}:00:00`; - }), - }, - }); - - chart.value?.updateSeries([ - { - name: props.options.collection, - data: metrics.value.map((metric) => - Number( - Number(metric[`${props.options.valueField}_${props.options.function}`]).toFixed( - props.options.decimals ?? 0 - ) - ) - ), - }, - ]); - } catch (err) { - error.value = err; - } finally { - loading.value = false; - } } }, }); diff --git a/app/src/utils/adjust-date.ts b/app/src/utils/adjust-date.ts new file mode 100644 index 0000000000..bcbcf952ad --- /dev/null +++ b/app/src/utils/adjust-date.ts @@ -0,0 +1,99 @@ +/** + * @TODO this has to go in a shared package. Another one copy pasted from the API + */ + +import { + addYears, + subWeeks, + subYears, + addWeeks, + subMonths, + addMonths, + subDays, + addDays, + subHours, + addHours, + subMinutes, + addMinutes, + subSeconds, + addSeconds, + addMilliseconds, + subMilliseconds, +} from 'date-fns'; +import { clone } from 'lodash'; + +/** + * Adjust a given date by a given change in duration. The adjustment value uses the exact same syntax + * and logic as Vercel's `ms`. + * + * The conversion is lifted straight from `ms`. + */ +export function adjustDate(date: Date, adjustment: string): Date | undefined { + date = clone(date); + + const subtract = adjustment.startsWith('-'); + + if (subtract || adjustment.startsWith('+')) { + adjustment = adjustment.substring(1); + } + + const match = + /^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|months?|mth|mo|years?|yrs?|y)?$/i.exec( + adjustment + ); + + if (!match) { + return; + } + + const amount = parseFloat(match[1]); + const type = (match[2] || 'days').toLowerCase(); + + switch (type) { + case 'years': + case 'year': + case 'yrs': + case 'yr': + case 'y': + return subtract ? subYears(date, amount) : addYears(date, amount); + case 'months': + case 'month': + case 'mth': + case 'mo': + return subtract ? subMonths(date, amount) : addMonths(date, amount); + case 'weeks': + case 'week': + case 'w': + return subtract ? subWeeks(date, amount) : addWeeks(date, amount); + case 'days': + case 'day': + case 'd': + return subtract ? subDays(date, amount) : addDays(date, amount); + case 'hours': + case 'hour': + case 'hrs': + case 'hr': + case 'h': + return subtract ? subHours(date, amount) : addHours(date, amount); + case 'minutes': + case 'minute': + case 'mins': + case 'min': + case 'm': + return subtract ? subMinutes(date, amount) : addMinutes(date, amount); + case 'seconds': + case 'second': + case 'secs': + case 'sec': + case 's': + return subtract ? subSeconds(date, amount) : addSeconds(date, amount); + case 'milliseconds': + case 'millisecond': + case 'msecs': + case 'msec': + case 'ms': + return subtract ? subMilliseconds(date, amount) : addMilliseconds(date, amount); + default: + return undefined; + } +}