diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml
index 6a67168448..24305bec4e 100644
--- a/app/src/lang/translations/en-US.yaml
+++ b/app/src/lang/translations/en-US.yaml
@@ -122,6 +122,9 @@ field_update_success: 'Updated Field: "{field}"'
duplicate_where_to: Where would you like to duplicate this field to?
language: Language
aggregate_function: Aggregate Function
+aggregate_precision: Aggregate Precision
+group_aggregation: Group Aggregation
+group_precision: Group Precision
global: Global
admins_have_all_permissions: Admins have all permissions
camera: Camera
@@ -376,10 +379,15 @@ collection_field_not_setup: The collection field option is misconfigured
select_a_collection: Select a Collection
select_a_field: Select a Field
active: Active
+move_to: Move To...
users: Users
activity: Activity
webhooks: Webhooks
decimals: Decimals
+value_decimals: Value Decimals
+min_value: Min Value
+max_value: Max Value
+automatic: Automatic
field_width: Field Width
add_filter: Add Filter
upper_limit: Upper limit...
diff --git a/app/src/modules/insights/components/panel.vue b/app/src/modules/insights/components/panel.vue
index 64d2021a5f..eba4d39260 100644
--- a/app/src/modules/insights/components/panel.vue
+++ b/app/src/modules/insights/components/panel.vue
@@ -21,13 +21,6 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('move_to') }}
+
+
+
+
+
+
+
+ {{ t('duplicate') }}
+
+
+
+
+
+
+ {{ t('delete') }}
+
+
+
@@ -68,6 +91,7 @@ import { useI18n } from 'vue-i18n';
export default defineComponent({
name: 'panel',
+ emits: ['update', 'move', 'duplicate', 'delete'],
props: {
panel: {
type: Object as PropType
,
@@ -360,16 +384,17 @@ export default defineComponent({
flex-grow: 1;
}
-.duplicate-icon,
+.more-icon,
.edit-icon,
-.delete-icon,
.note {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--foreground-normal);
}
-.delete-icon {
- --v-icon-color-hover: var(--danger);
+.delete-action {
+ --v-list-item-color: var(--danger);
+ --v-list-item-color-hover: var(--danger);
+ --v-list-item-icon-color: var(--danger);
}
.edit-actions {
diff --git a/app/src/modules/insights/components/workspace.vue b/app/src/modules/insights/components/workspace.vue
index db919beadc..318bbfc910 100644
--- a/app/src/modules/insights/components/workspace.vue
+++ b/app/src/modules/insights/components/workspace.vue
@@ -6,6 +6,7 @@
:panel="panel"
:edit-mode="editMode"
@update="$emit('update', { edits: $event, id: panel.id })"
+ @move="$emit('move', panel.id)"
@delete="$emit('delete', panel.id)"
@duplicate="$emit('duplicate', panel)"
/>
@@ -20,6 +21,7 @@ import { useElementSize } from '@/composables/use-element-size';
export default defineComponent({
name: 'insights-workspace',
+ emits: ['update', 'move', 'delete', 'duplicate'],
components: { InsightsPanel },
props: {
panels: {
diff --git a/app/src/modules/insights/routes/dashboard.vue b/app/src/modules/insights/routes/dashboard.vue
index f7423b848a..b1192af643 100644
--- a/app/src/modules/insights/routes/dashboard.vue
+++ b/app/src/modules/insights/routes/dashboard.vue
@@ -63,7 +63,8 @@
:panels="panels"
:zoom-to-fit="zoomToFit"
@update="stagePanelEdits"
- @delete="confirmDeletePanel = $event"
+ @move="movePanelID = $event"
+ @delete="deletePanel"
@duplicate="duplicatePanel"
/>
@@ -75,16 +76,20 @@
@cancel="$router.push(`/insights/${primaryKey}`)"
/>
-
+
- {{ t('panel_delete_confirm') }}
+ {{ t('move_to') }}
+
+
+
+
-
+
{{ t('cancel') }}
-
- {{ t('delete') }}
+
+ {{ t('move') }}
@@ -130,9 +135,12 @@ export default defineComponent({
const { fullScreen } = toRefs(appStore);
const editMode = ref(false);
- const confirmDeletePanel = ref(null);
- const deletingPanel = ref(false);
const saving = ref(false);
+ const movePanelLoading = ref(false);
+
+ const movePanelTo = ref(props.primaryKey);
+
+ const movePanelID = ref();
const zoomToFit = ref(false);
@@ -140,10 +148,17 @@ export default defineComponent({
insightsStore.dashboards.find((dashboard) => dashboard.id === props.primaryKey)
);
+ const movePanelChoices = computed(() => {
+ return insightsStore.dashboards;
+ });
+
const stagedPanels = ref[]>([]);
+ const panelsToBeDeleted = ref([]);
const panels = computed(() => {
- const savedPanels = currentDashboard.value?.panels || [];
+ const savedPanels = (currentDashboard.value?.panels || []).filter(
+ (panel) => panelsToBeDeleted.value.includes(panel.id) === false
+ );
const raw = [
...savedPanels.map((panel) => {
@@ -212,17 +227,20 @@ export default defineComponent({
saving,
saveChanges,
stageConfiguration,
- deletingPanel,
+ movePanelID,
+ movePanel,
deletePanel,
- confirmDeletePanel,
cancelChanges,
duplicatePanel,
+ movePanelLoading,
t,
toggleFullScreen,
zoomToFit,
fullScreen,
toggleZoomToFit,
md,
+ movePanelChoices,
+ movePanelTo,
};
function stagePanelEdits(event: { edits: Partial; id?: string }) {
@@ -281,6 +299,8 @@ export default defineComponent({
panels: updatedPanels,
});
+ await api.delete(`/panels`, { data: panelsToBeDeleted.value });
+
await insightsStore.hydrate();
stagedPanels.value = [];
@@ -292,27 +312,16 @@ export default defineComponent({
}
}
- async function deletePanel() {
- if (!currentDashboard.value || !confirmDeletePanel.value) return;
+ async function deletePanel(id: string) {
+ if (!currentDashboard.value) return;
- deletingPanel.value = true;
-
- try {
- if (confirmDeletePanel.value.startsWith('_') === false) {
- await api.delete(`/panels/${confirmDeletePanel.value}`);
- await insightsStore.hydrate();
- }
- stagedPanels.value = stagedPanels.value.filter((panel) => panel.id !== confirmDeletePanel.value);
- confirmDeletePanel.value = null;
- } catch (err) {
- unexpectedError(err);
- } finally {
- deletingPanel.value = false;
- }
+ stagedPanels.value = stagedPanels.value.filter((panel) => panel.id !== id);
+ if (id.startsWith('_') === false) panelsToBeDeleted.value.push(id);
}
function cancelChanges() {
stagedPanels.value = [];
+ panelsToBeDeleted.value = [];
editMode.value = false;
}
@@ -330,6 +339,26 @@ export default defineComponent({
function toggleZoomToFit() {
zoomToFit.value = !zoomToFit.value;
}
+
+ async function movePanel() {
+ movePanelLoading.value = true;
+
+ try {
+ await api.patch(`/panels/${movePanelID.value}`, {
+ dashboard: movePanelTo.value,
+ });
+
+ stagedPanels.value = stagedPanels.value.filter((panel) => panel.id !== movePanelID.value);
+
+ await insightsStore.hydrate();
+
+ movePanelID.value = null;
+ } catch (err) {
+ unexpectedError(err);
+ } finally {
+ movePanelLoading.value = false;
+ }
+ }
},
});
diff --git a/app/src/panels/metric/metric.vue b/app/src/panels/metric/metric.vue
index 5025f95191..6ae265f7dc 100644
--- a/app/src/panels/metric/metric.vue
+++ b/app/src/panels/metric/metric.vue
@@ -75,7 +75,7 @@ export default defineComponent({
if (!metric.value) return null;
if (props.options.abbreviate) {
- return abbreviateNumber(metric.value);
+ return abbreviateNumber(metric.value, props.options.decimals);
}
return n(Number(metric.value), 'decimal', {
diff --git a/app/src/panels/time-series/index.ts b/app/src/panels/time-series/index.ts
index dbc2e2204c..a57d7ea790 100644
--- a/app/src/panels/time-series/index.ts
+++ b/app/src/panels/time-series/index.ts
@@ -17,10 +17,22 @@ export default definePanel({
width: 'half',
},
},
+ {
+ field: 'color',
+ name: '$t:color',
+ type: 'string',
+ schema: {
+ default_value: '#00C897',
+ },
+ meta: {
+ interface: 'select-color',
+ width: 'half',
+ },
+ },
{
field: 'function',
type: 'string',
- name: '$t:aggregate_function',
+ name: '$t:group_aggregation',
meta: {
width: 'half',
interface: 'select-dropdown',
@@ -62,6 +74,46 @@ export default definePanel({
},
},
},
+ {
+ field: 'precision',
+ type: 'string',
+ name: '$t:group_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: 'dateField',
type: 'string',
@@ -75,19 +127,6 @@ export default definePanel({
width: 'half',
},
},
- {
- field: 'valueField',
- type: 'string',
- name: '$t:panels.time_series.value_field',
- meta: {
- interface: 'system-field',
- options: {
- collectionField: 'collection',
- typeAllowList: ['integer', 'bigInteger', 'float', 'decimal'],
- },
- width: 'half',
- },
- },
{
field: 'range',
type: 'dropdown',
@@ -145,10 +184,23 @@ export default definePanel({
},
},
},
+ {
+ field: 'valueField',
+ type: 'string',
+ name: '$t:panels.time_series.value_field',
+ meta: {
+ interface: 'system-field',
+ options: {
+ collectionField: 'collection',
+ typeAllowList: ['integer', 'bigInteger', 'float', 'decimal'],
+ },
+ width: 'half',
+ },
+ },
{
field: 'decimals',
type: 'integer',
- name: '$t:decimals',
+ name: '$t:value_decimals',
meta: {
interface: 'input',
width: 'half',
@@ -161,64 +213,27 @@ export default definePanel({
},
},
{
- field: 'precision',
- type: 'string',
- name: '$t:precision',
+ field: 'min',
+ type: 'integer',
+ name: '$t:min_value',
meta: {
- interface: 'select-dropdown',
+ interface: 'input',
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',
- },
- ],
+ placeholder: '$t:automatic',
},
},
- schema: {
- default_value: 'hour',
- },
},
{
- field: 'showZero',
- name: '$t:show_zero',
- type: 'boolean',
+ field: 'max',
+ type: 'integer',
+ name: '$t:max_value',
meta: {
- interface: 'boolean',
- width: 'half',
- },
- },
- {
- field: 'color',
- name: '$t:color',
- type: 'string',
- schema: {
- default_value: '#00C897',
- },
- meta: {
- interface: 'select-color',
+ interface: 'input',
width: 'half',
+ options: {
+ placeholder: '$t:automatic',
+ },
},
},
{
diff --git a/app/src/panels/time-series/time-series.vue b/app/src/panels/time-series/time-series.vue
index 78a84fcc4e..314f13b2ef 100644
--- a/app/src/panels/time-series/time-series.vue
+++ b/app/src/panels/time-series/time-series.vue
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n';
import { isEqual } from 'lodash';
import { useFieldsStore } from '@/stores';
import { Filter } from '@/types';
+import { abbreviateNumber } from '@/utils/abbreviate-number';
type TimeSeriesOptions = {
collection: string;
@@ -21,7 +22,8 @@ type TimeSeriesOptions = {
color: string;
decimals: number;
precision: 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second';
- showZero: boolean;
+ min?: number;
+ max?: number;
filter: Filter | null;
};
@@ -58,9 +60,9 @@ export default defineComponent({
});
watch(
- [() => props.options, () => props.show_header],
- (newOptions, oldOptions) => {
- if (isEqual(newOptions, oldOptions) === false) {
+ [() => props.options, () => props.show_header, () => props.height],
+ (newVal, oldVal) => {
+ if (isEqual(newVal, oldVal) === false) {
fetchData();
chart.value?.destroy();
setupChart();
@@ -262,7 +264,14 @@ export default defineComponent({
},
yaxis: {
forceNiceScale: true,
- min: props.options.showZero ? 0 : undefined,
+ min: props.options.min ? Number(props.options.min) : undefined,
+ max: props.options.max ? Number(props.options.max) : undefined,
+ tickAmount: props.height - 2,
+ labels: {
+ formatter: (value: number) => {
+ return value > 10000 ? abbreviateNumber(value, 1) : n(value);
+ },
+ },
},
});
diff --git a/app/src/utils/abbreviate-number.ts b/app/src/utils/abbreviate-number.ts
index 08479f49c0..d93b8df7c5 100644
--- a/app/src/utils/abbreviate-number.ts
+++ b/app/src/utils/abbreviate-number.ts
@@ -1,25 +1,38 @@
-export function abbreviateNumber(value: number): number | string {
- if (value >= 1000) {
- value = Math.round(value);
+export function abbreviateNumber(
+ number: number,
+ decimalPlaces = 0,
+ units: string[] = ['K', 'M', 'B', 'T']
+): number | string {
+ const isNegative = number < 0;
- const suffixes = ['', 'K', 'M', 'B', 'T'];
- const suffixNum = Math.floor(('' + value).length / 3);
- let shortValue: number = value;
+ number = Math.abs(number);
- for (let precision = 2; precision >= 1; precision--) {
- shortValue = parseFloat((suffixNum != 0 ? value / Math.pow(1000, suffixNum) : value).toPrecision(precision));
- const dotLessShortValue = (shortValue + '').replace(/[^a-zA-Z 0-9]+/g, '');
- if (dotLessShortValue.length <= 2) break;
+ let stringValue = String(number);
+
+ if (number >= 1000) {
+ const precisionScale = Math.pow(10, decimalPlaces);
+
+ for (let i = units.length - 1; i >= 0; i--) {
+ const size = Math.pow(10, (i + 1) * 3);
+
+ if (size <= number) {
+ number = Math.round((number * precisionScale) / size) / precisionScale;
+
+ if (number === 1000 && i < units.length - 1) {
+ number = 1;
+ i++;
+ }
+
+ stringValue = number + units[i];
+
+ break;
+ }
}
-
- let valueAsString = String(shortValue);
-
- if (shortValue % 1 !== 0) {
- valueAsString = shortValue.toFixed(1);
- }
-
- return valueAsString + suffixes[suffixNum];
}
- return value;
+ if (isNegative) {
+ stringValue = `-${stringValue}`;
+ }
+
+ return stringValue;
}