mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(editor): Insights dashboard (#13739)
Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com> Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
This commit is contained in:
@@ -49,6 +49,8 @@
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.36",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"element-plus": "catalog:frontend",
|
||||
"is-emoji-supported": "^0.0.5",
|
||||
"markdown-it": "^13.0.2",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, within } from '@testing-library/vue';
|
||||
|
||||
import N8nDataTableServer, { type TableHeader } from './N8nDataTableServer.vue';
|
||||
|
||||
const itemFactory = () => ({
|
||||
id: crypto.randomUUID() as string,
|
||||
firstName: crypto.randomUUID() as string,
|
||||
lastName: crypto.randomUUID() as string,
|
||||
});
|
||||
type Item = ReturnType<typeof itemFactory>;
|
||||
|
||||
const items: Item[] = [...Array(20).keys()].map(itemFactory);
|
||||
const headers: Array<TableHeader<Item>> = [
|
||||
{
|
||||
title: 'Id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: 'First name',
|
||||
key: 'firstName',
|
||||
},
|
||||
{
|
||||
title: 'Last name',
|
||||
value: 'lastName',
|
||||
},
|
||||
{
|
||||
title: 'Full name',
|
||||
key: 'fullName',
|
||||
value(item: Item) {
|
||||
return `${item.firstName}|${item.lastName}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Empty Column',
|
||||
},
|
||||
];
|
||||
|
||||
describe('N8nDataTableServer', () => {
|
||||
it('should render a table', () => {
|
||||
const { container } = render(N8nDataTableServer, {
|
||||
//@ts-expect-error testing-library errors due to header generics
|
||||
props: { items, headers, itemsLength: items.length },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll('thead tr').length).toEqual(1);
|
||||
expect(container.querySelectorAll('tbody tr').length).toEqual(items.length);
|
||||
expect(container.querySelectorAll('tbody tr td').length).toEqual(headers.length * items.length);
|
||||
});
|
||||
|
||||
it('should render dynamic slots', () => {
|
||||
const slotName = 'item.id' as `item.${string}`;
|
||||
const { container } = render(N8nDataTableServer, {
|
||||
//@ts-expect-error testing-library errors due to header generics
|
||||
props: { items, headers, itemsLength: items.length },
|
||||
slots: {
|
||||
[slotName]: ({ item }: { item: Item }) => {
|
||||
return `🍌 ${item.id}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach((tr, index) => {
|
||||
expect(tr.querySelector('td')?.textContent).toBe(`🍌 ${items[index].id}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should synchronize the state', async () => {
|
||||
const { container, rerender } = render(N8nDataTableServer, {
|
||||
//@ts-expect-error testing-library errors due to header generics
|
||||
props: { items, headers, itemsLength: items.length },
|
||||
});
|
||||
|
||||
expect(container.querySelector('tbody tr td')?.textContent).toBe(items[0].id);
|
||||
|
||||
await rerender({
|
||||
items: [{ id: '1', firstName: '1', lastName: '1' }],
|
||||
headers,
|
||||
itemsLength: 1,
|
||||
});
|
||||
expect(container.querySelector('tbody tr td')?.textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('should emit options for sorting / pagination', async () => {
|
||||
const { container, emitted, getByTestId } = render(N8nDataTableServer, {
|
||||
//@ts-expect-error testing-library errors due to header generics
|
||||
props: { items, headers, itemsLength: 100 },
|
||||
});
|
||||
|
||||
await userEvent.click(container.querySelector('thead tr th')!);
|
||||
await userEvent.click(container.querySelector('thead tr th')!);
|
||||
await userEvent.click(within(getByTestId('pagination')).getByLabelText('page 2'));
|
||||
|
||||
expect(emitted('update:options').length).toBe(3);
|
||||
expect(emitted('update:options')[0]).toStrictEqual([
|
||||
expect.objectContaining({ sortBy: [{ id: 'id', desc: false }] }),
|
||||
]);
|
||||
expect(emitted('update:options')[1]).toStrictEqual([
|
||||
expect.objectContaining({ sortBy: [{ id: 'id', desc: true }] }),
|
||||
]);
|
||||
expect(emitted('update:options')[2]).toStrictEqual([expect.objectContaining({ page: 1 })]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,668 @@
|
||||
<script lang="ts">
|
||||
export type TableHeader<T> = {
|
||||
title?: string;
|
||||
key?: DeepKeys<T> | string;
|
||||
value?: DeepKeys<T> | AccessorFn<T>;
|
||||
disableSort?: boolean;
|
||||
minWidth?: number;
|
||||
width?: number;
|
||||
align?: 'end' | 'start' | 'center';
|
||||
} & (
|
||||
| { title: string; key?: never; value?: never } // Ensures an object with only `title` is valid
|
||||
| { key: DeepKeys<T> }
|
||||
| { value: DeepKeys<T>; key?: string }
|
||||
| { key: string; value: AccessorFn<T> }
|
||||
);
|
||||
export type TableSortBy = SortingState;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import type {
|
||||
AccessorFn,
|
||||
Cell,
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
CoreColumn,
|
||||
CoreOptions,
|
||||
DeepKeys,
|
||||
PaginationState,
|
||||
Row,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
Updater,
|
||||
} from '@tanstack/vue-table';
|
||||
import { createColumnHelper, FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
|
||||
import { ElCheckbox } from 'element-plus';
|
||||
import { get } from 'lodash-es';
|
||||
import { computed, h, ref, shallowRef, useSlots, watch } from 'vue';
|
||||
|
||||
import N8nPagination from '../N8nPagination';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[];
|
||||
headers: Array<TableHeader<T>>;
|
||||
itemsLength: number;
|
||||
loading?: boolean;
|
||||
multiSort?: boolean;
|
||||
|
||||
showSelect?: boolean;
|
||||
/**
|
||||
* For the selection feature to work, the data table must be able to differentiate each row in the data set. This is done using the item-value prop. It designates a property on the item that should contain a unique value. By default the property it looks for is id.
|
||||
* You can also supply a function, if for example the unique value needs to be a composite of several properties. The function receives each item as its first argument
|
||||
*/
|
||||
itemValue?: CoreOptions<T>['getRowId'] | string;
|
||||
returnObject?: boolean;
|
||||
|
||||
itemSelectable?: boolean | DeepKeys<T> | ((row: T) => boolean);
|
||||
}>(),
|
||||
{
|
||||
itemSelectable: undefined,
|
||||
itemValue: 'id',
|
||||
},
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
defineSlots<{
|
||||
[key: `item.${string}`]: (props: { value: unknown; item: T }) => void;
|
||||
item: (props: { item: T; cells: Array<Cell<T, unknown>> }) => void;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'update:options': [
|
||||
payload: {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
sortBy: Array<{ id: string; desc: boolean }>;
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'click:row': [event: MouseEvent, payload: { item: T }];
|
||||
}>();
|
||||
|
||||
const data = shallowRef<T[]>(props.items.concat());
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
data.value = props.items.concat();
|
||||
},
|
||||
// the sync will NOT work without the deep watcher
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function itemKeySlot(info: CellContext<T, unknown>) {
|
||||
const slotName = `item.${info.column.id}` as const;
|
||||
return slots[slotName]
|
||||
? slots[slotName]({ item: info.row.original, value: info.getValue() })
|
||||
: info.getValue();
|
||||
}
|
||||
|
||||
function isValueAccessor(column: TableHeader<T>): column is Required<TableHeader<T>> {
|
||||
return !!column.value;
|
||||
}
|
||||
|
||||
function getHeaderTitle(column: TableHeader<T>) {
|
||||
const value = typeof column.value === 'function' ? '' : column.value;
|
||||
|
||||
return column.title ?? column.key ?? value;
|
||||
}
|
||||
|
||||
function isAccessorColumn(
|
||||
column: TableHeader<T>,
|
||||
): column is Omit<TableHeader<T>, 'key' | 'value'> & { key: string; value: AccessorFn<T> } {
|
||||
return typeof column.value === 'function';
|
||||
}
|
||||
|
||||
type ColumnMeta = {
|
||||
cellProps: {
|
||||
align?: 'end' | 'start' | 'center';
|
||||
};
|
||||
};
|
||||
|
||||
const getColumnMeta = (column: CoreColumn<T, unknown>) => {
|
||||
return (column.columnDef.meta ?? {}) as ColumnMeta;
|
||||
};
|
||||
|
||||
const MIN_COLUMN_WIDTH = 75;
|
||||
|
||||
function getValueAccessor(column: Required<TableHeader<T>>) {
|
||||
if (isAccessorColumn(column)) {
|
||||
return columnHelper.accessor(column.value, {
|
||||
id: column.key,
|
||||
cell: itemKeySlot,
|
||||
header: () => getHeaderTitle(column),
|
||||
enableSorting: !column.disableSort,
|
||||
minSize: column.minWidth ?? MIN_COLUMN_WIDTH,
|
||||
size: column.width,
|
||||
meta: {
|
||||
cellProps: {
|
||||
align: column.align,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return columnHelper.accessor(column.value, {
|
||||
id: column.key ?? column.value,
|
||||
cell: itemKeySlot,
|
||||
header: () => getHeaderTitle(column),
|
||||
enableSorting: !column.disableSort,
|
||||
minSize: column.minWidth ?? MIN_COLUMN_WIDTH,
|
||||
size: column.width,
|
||||
meta: {
|
||||
cellProps: {
|
||||
align: column.align,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapHeaders(columns: Array<TableHeader<T>>) {
|
||||
return columns.map((column, index) => {
|
||||
if (isValueAccessor(column)) {
|
||||
return getValueAccessor(column);
|
||||
}
|
||||
|
||||
if (column.key) {
|
||||
const accessor = column.key;
|
||||
//@ts-expect-error key is marked as a string
|
||||
return columnHelper.accessor(column.key, {
|
||||
id: accessor,
|
||||
cell: itemKeySlot,
|
||||
header: () => getHeaderTitle(column),
|
||||
enableSorting: !column.disableSort,
|
||||
minSize: column.minWidth ?? MIN_COLUMN_WIDTH,
|
||||
size: column.width,
|
||||
meta: {
|
||||
cellProps: {
|
||||
align: column.align,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return columnHelper.display({
|
||||
id: `display_column_${index}`,
|
||||
header: () => getHeaderTitle(column),
|
||||
size: column.width,
|
||||
meta: {
|
||||
cellProps: {
|
||||
align: column.align,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const columnsDefinition = computed(() => {
|
||||
return [...(props.showSelect ? [selectColumn] : []), ...mapHeaders(props.headers)];
|
||||
});
|
||||
|
||||
const page = defineModel<number>('page', { default: 0 });
|
||||
watch(page, () => table.setPageIndex(page.value));
|
||||
|
||||
const itemsPerPage = defineModel<number>('items-per-page', { default: 10 });
|
||||
watch(itemsPerPage, () => table.setPageSize(itemsPerPage.value));
|
||||
|
||||
const pagination = computed<PaginationState>({
|
||||
get() {
|
||||
return {
|
||||
pageIndex: page.value,
|
||||
pageSize: itemsPerPage.value,
|
||||
};
|
||||
},
|
||||
set(newValue) {
|
||||
page.value = newValue.pageIndex;
|
||||
itemsPerPage.value = newValue.pageSize;
|
||||
},
|
||||
});
|
||||
|
||||
const sortBy = defineModel<SortingState>('sort-by', { default: [], required: false });
|
||||
|
||||
function handleSortingChange(updaterOrValue: Updater<SortingState>) {
|
||||
sortBy.value =
|
||||
typeof updaterOrValue === 'function' ? updaterOrValue(sortBy.value) : updaterOrValue;
|
||||
|
||||
emit('update:options', {
|
||||
page: page.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
sortBy: sortBy.value,
|
||||
});
|
||||
}
|
||||
|
||||
const SELECT_COLUMN_ID = 'data-table-select';
|
||||
const selectColumn: ColumnDef<T> = {
|
||||
id: SELECT_COLUMN_ID,
|
||||
enableResizing: false,
|
||||
size: 38,
|
||||
enablePinning: true,
|
||||
header: ({ table }) => {
|
||||
const checkboxRef = ref<typeof ElCheckbox>();
|
||||
return h(ElCheckbox, {
|
||||
ref: checkboxRef,
|
||||
modelValue: table.getIsAllRowsSelected(),
|
||||
indeterminate: table.getIsSomeRowsSelected(),
|
||||
onChange: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const input = checkboxRef.value?.$el.getElementsByTagName('input')[0];
|
||||
if (!input) return;
|
||||
table.getToggleAllRowsSelectedHandler()?.({ target: input });
|
||||
},
|
||||
});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const checkboxRef = ref<typeof ElCheckbox>();
|
||||
return h(ElCheckbox, {
|
||||
ref: checkboxRef,
|
||||
modelValue: row.getIsSelected(),
|
||||
disabled: !row.getCanSelect(),
|
||||
onChange: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const input = checkboxRef.value?.$el.getElementsByTagName('input')[0];
|
||||
if (!input) return;
|
||||
row.getToggleSelectedHandler()?.({ target: input });
|
||||
},
|
||||
});
|
||||
},
|
||||
meta: {
|
||||
cellProps: {
|
||||
align: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getRowId(originalRow: T, index: number, parent?: Row<T>): string {
|
||||
if (typeof props.itemValue === 'function') {
|
||||
return props.itemValue(originalRow, index, parent);
|
||||
}
|
||||
|
||||
return String(get(originalRow, props.itemValue));
|
||||
}
|
||||
|
||||
function handleRowSelectionChange(updaterOrValue: Updater<RowSelectionState>) {
|
||||
if (typeof updaterOrValue === 'function') {
|
||||
rowSelection.value = updaterOrValue(rowSelection.value);
|
||||
} else {
|
||||
rowSelection.value = updaterOrValue;
|
||||
}
|
||||
|
||||
if (props.returnObject) {
|
||||
selection.value = Object.keys(rowSelection.value).map((id) => table.getRow(id).original);
|
||||
} else {
|
||||
selection.value = Object.keys(rowSelection.value);
|
||||
}
|
||||
}
|
||||
|
||||
const selection = defineModel<string[] | T[]>('selection');
|
||||
const rowSelection = ref(
|
||||
(selection.value ?? []).reduce<RowSelectionState>((acc, item, index) => {
|
||||
const key = typeof item === 'string' ? item : getRowId(item, index);
|
||||
acc[key] = true;
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const columnHelper = createColumnHelper<T>();
|
||||
const table = useVueTable({
|
||||
data,
|
||||
columns: columnsDefinition.value,
|
||||
get rowCount() {
|
||||
return props.itemsLength;
|
||||
},
|
||||
state: {
|
||||
get sorting() {
|
||||
return sortBy.value;
|
||||
},
|
||||
get pagination() {
|
||||
return pagination.value;
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value;
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: handleSortingChange,
|
||||
onPaginationChange(updaterOrValue) {
|
||||
pagination.value =
|
||||
typeof updaterOrValue === 'function' ? updaterOrValue(pagination.value) : updaterOrValue;
|
||||
|
||||
emit('update:options', {
|
||||
page: page.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
sortBy: sortBy.value,
|
||||
});
|
||||
},
|
||||
manualSorting: true,
|
||||
enableMultiSort: props.multiSort,
|
||||
manualPagination: true,
|
||||
columnResizeMode: 'onChange',
|
||||
columnResizeDirection: 'ltr',
|
||||
getRowId,
|
||||
enableRowSelection: (row) => {
|
||||
if (typeof props.itemSelectable === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
if (typeof props.itemSelectable === 'boolean') {
|
||||
return props.itemSelectable;
|
||||
}
|
||||
|
||||
if (typeof props.itemSelectable === 'function') {
|
||||
return props.itemSelectable(row.original);
|
||||
}
|
||||
|
||||
return Boolean(get(row.original, props.itemSelectable));
|
||||
},
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="n8n-data-table-server-wrapper">
|
||||
<div class="table-scroll">
|
||||
<table
|
||||
class="n8n-data-table-server"
|
||||
:class="{ 'table--loading': loading }"
|
||||
:style="{
|
||||
width: `${table.getCenterTotalSize()}px`,
|
||||
borderSpacing: 0,
|
||||
minWidth: '100%',
|
||||
tableLayout: 'fixed',
|
||||
}"
|
||||
>
|
||||
<thead :style="{ position: 'sticky', top: 0, zIndex: 2 }" :class="{ loading }">
|
||||
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<template v-for="header in headerGroup.headers" :key="header.id">
|
||||
<th
|
||||
:style="{
|
||||
cursor: header.column.getCanSort() ? 'pointer' : undefined,
|
||||
width: `${header.getSize()}px`,
|
||||
}"
|
||||
@mousedown="header.column.getToggleSortingHandler()?.($event)"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
|
||||
<template v-if="header.column.getCanSort()">
|
||||
{{ { asc: '↑', desc: '↓' }[header.column.getIsSorted() as string] }}
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="header.column.getCanResize()"
|
||||
:class="{ resizer: true, ['is-resizing']: header.column.getIsResizing() }"
|
||||
@mousedown.stop="header.getResizeHandler()?.($event)"
|
||||
@touchstart="header.getResizeHandler()?.($event)"
|
||||
@dblclick="header.column.resetSize()"
|
||||
></div>
|
||||
</th>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="loading">
|
||||
<th :colspan="table.getVisibleFlatColumns().length" class="loading-row">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-value"></div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="loading && !table.getRowModel().rows.length">
|
||||
<tr v-for="item in itemsPerPage" :key="item">
|
||||
<td
|
||||
v-for="coll in table.getVisibleFlatColumns()"
|
||||
:key="coll.id"
|
||||
class="el-skeleton is-animated"
|
||||
>
|
||||
<el-skeleton-item />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else-if="table.getRowModel().rows.length">
|
||||
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<slot name="item" v-bind="{ item: row.original, cells: row.getVisibleCells() }">
|
||||
<tr @click="emit('click:row', $event, { item: row.original })">
|
||||
<template v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<td
|
||||
:class="{
|
||||
[`cell-align--${getColumnMeta(cell.column).cellProps.align}`]: Boolean(
|
||||
getColumnMeta(cell.column).cellProps.align,
|
||||
),
|
||||
}"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</slot>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-pagination" data-test-id="pagination">
|
||||
<N8nPagination
|
||||
:current-page="page + 1"
|
||||
:page-size="itemsPerPage"
|
||||
:page-sizes="[10, 20, 30, 40]"
|
||||
layout="prev, pager, next"
|
||||
:total="itemsLength"
|
||||
@update:current-page="page = $event - 1"
|
||||
>
|
||||
</N8nPagination>
|
||||
<div class="table-pagination__sizes">
|
||||
<div class="table-pagination__sizes__label">Page size</div>
|
||||
<el-select
|
||||
v-model.number="itemsPerPage"
|
||||
class="table-pagination__sizes__select"
|
||||
size="small"
|
||||
:teleported="false"
|
||||
>
|
||||
<el-option v-for="item in [10, 20, 30, 40]" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.n8n-data-table-server {
|
||||
height: 100%;
|
||||
font-size: var(--font-size-s);
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
> thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: var(--color-background-light-base);
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--color-text-base);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
text-transform: capitalize;
|
||||
height: 36px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
tbody > tr {
|
||||
&:hover {
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
|
||||
&:last-child > td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
background-color: var(--color-background-xlight);
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--color-text-dark);
|
||||
padding: 0 8px;
|
||||
height: 48px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.n8n-data-table-server-wrapper {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
th.loading-row {
|
||||
background-color: transparent;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
height: 0px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-value {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--color-primary);
|
||||
animation: indeterminateAnimation 1s infinite linear;
|
||||
transform-origin: 0% 50%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@keyframes indeterminateAnimation {
|
||||
0% {
|
||||
transform: translateX(0) scaleX(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(0) scaleX(0.4);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%) scaleX(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.table--loading {
|
||||
td {
|
||||
opacity: 0.38;
|
||||
}
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
&__sizes {
|
||||
display: flex;
|
||||
|
||||
&__label {
|
||||
color: var(--color-text-base);
|
||||
background-color: var(--color-background-light);
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
border-right: 0;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-top-left-radius: var(--border-radius-base);
|
||||
border-bottom-left-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
&__select {
|
||||
--input-border-top-left-radius: 0;
|
||||
--input-border-bottom-left-radius: 0;
|
||||
width: 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
background: var(--color-primary);
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
right: -2px;
|
||||
display: none;
|
||||
z-index: 1;
|
||||
&:hover {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.resizer.is-resizing {
|
||||
display: block;
|
||||
}
|
||||
|
||||
th:hover:not(:last-child) > .resizer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cell-align {
|
||||
&--end {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
&--center {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
import N8nDataTableServer from './N8nDataTableServer.vue';
|
||||
|
||||
export default N8nDataTableServer;
|
||||
@@ -59,3 +59,4 @@ export { N8nKeyboardShortcut } from './N8nKeyboardShortcut';
|
||||
export { default as N8nIconPicker } from './N8nIconPicker';
|
||||
export { default as N8nBreadcrumbs } from './N8nBreadcrumbs';
|
||||
export { default as N8nTableBase } from './TableBase';
|
||||
export { default as N8nDataTableServer } from './N8nDataTableServer';
|
||||
|
||||
@@ -104,6 +104,15 @@ const mainMenuItems = computed(() => [
|
||||
position: 'bottom',
|
||||
route: { to: { name: VIEWS.VARIABLES } },
|
||||
},
|
||||
{
|
||||
id: 'insights',
|
||||
icon: 'chart-bar',
|
||||
label: 'Insights',
|
||||
customIconSize: 'medium',
|
||||
position: 'bottom',
|
||||
route: { to: { name: VIEWS.INSIGHTS } },
|
||||
available: hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }),
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
icon: 'question',
|
||||
|
||||
@@ -550,6 +550,7 @@ export const enum VIEWS {
|
||||
PROJECTS_EXECUTIONS = 'ProjectsExecutions',
|
||||
FOLDERS = 'Folders',
|
||||
PROJECTS_FOLDERS = 'ProjectsFolders',
|
||||
INSIGHTS = 'Insights',
|
||||
}
|
||||
|
||||
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { merge } from 'lodash-es';
|
||||
import { type ChartOptions, type ScriptableContext } from 'chart.js';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||
|
||||
/**
|
||||
*
|
||||
* Chart js configuration
|
||||
*/
|
||||
|
||||
export const generateLinearGradient = (ctx: CanvasRenderingContext2D, height: number) => {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, 'rgba(255, 111,92, 1)');
|
||||
gradient.addColorStop(0.8, 'rgba(255, 111, 92, 0.25)');
|
||||
gradient.addColorStop(1, 'rgba(255, 111, 92, 0)');
|
||||
return gradient;
|
||||
};
|
||||
|
||||
export const generateLineChartOptions = (
|
||||
overrides: ChartOptions<'line'> = {},
|
||||
): ChartOptions<'line'> => {
|
||||
const colorTextDark = useCssVar('--color-text-dark', document.body);
|
||||
const colorBackgroundLight = useCssVar('--color-background-xlight', document.body);
|
||||
const colorForeGroundBase = useCssVar('--color-foreground-base', document.body);
|
||||
|
||||
const colorTextLight = useCssVar('--color-text-light', document.body);
|
||||
|
||||
return merge(
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
caretSize: 0,
|
||||
xAlign: 'center',
|
||||
yAlign: 'bottom',
|
||||
padding: 16,
|
||||
titleFont: {
|
||||
size: 14,
|
||||
},
|
||||
bodyFont: {
|
||||
size: 14,
|
||||
},
|
||||
backgroundColor: colorBackgroundLight.value,
|
||||
titleColor: colorTextDark.value,
|
||||
bodyColor: colorTextDark.value,
|
||||
borderWidth: 1,
|
||||
borderColor: colorForeGroundBase.value,
|
||||
callbacks: {
|
||||
label(context: ScriptableContext<'line'>) {
|
||||
const label = context.dataset.label ?? '';
|
||||
return `${label} ${smartDecimal(context.parsed.y)}`;
|
||||
},
|
||||
labelColor(context: ScriptableContext<'line'>) {
|
||||
return {
|
||||
borderColor: 'rgba(0, 0, 0, 0)',
|
||||
backgroundColor: context.dataset.backgroundColor as string,
|
||||
borderWidth: 0,
|
||||
borderRadius: 2,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: colorTextLight.value,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: colorForeGroundBase.value,
|
||||
},
|
||||
stacked: true,
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxTicksLimit: 3,
|
||||
color: colorTextLight.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
overrides,
|
||||
);
|
||||
};
|
||||
|
||||
export const generateBarChartOptions = (
|
||||
overrides: ChartOptions<'bar'> = {},
|
||||
): ChartOptions<'bar'> => {
|
||||
const colorTextLight = useCssVar('--color-text-light', document.body);
|
||||
const colorTextDark = useCssVar('--color-text-dark', document.body);
|
||||
const colorBackgroundLight = useCssVar('--color-background-xlight', document.body);
|
||||
const colorForeGroundBase = useCssVar('--color-foreground-base', document.body);
|
||||
|
||||
return merge(
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
align: 'end',
|
||||
reverse: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
borderRadius: 2,
|
||||
useBorderRadius: true,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
caretSize: 0,
|
||||
xAlign: 'center',
|
||||
yAlign: 'bottom',
|
||||
padding: 16,
|
||||
titleFont: {
|
||||
size: 14,
|
||||
},
|
||||
bodyFont: {
|
||||
size: 14,
|
||||
},
|
||||
backgroundColor: colorBackgroundLight.value,
|
||||
titleColor: colorTextDark.value,
|
||||
bodyColor: colorTextDark.value,
|
||||
borderWidth: 1,
|
||||
borderColor: colorForeGroundBase.value,
|
||||
callbacks: {
|
||||
label(context: ScriptableContext<'bar'>) {
|
||||
const label = context.dataset.label ?? '';
|
||||
return `${label} ${context.parsed.y}`;
|
||||
},
|
||||
labelColor(context: ScriptableContext<'bar'>) {
|
||||
return {
|
||||
borderColor: 'rgba(0, 0, 0, 0)',
|
||||
backgroundColor: context.dataset.backgroundColor as string,
|
||||
borderWidth: 0,
|
||||
borderRadius: 2,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false,
|
||||
},
|
||||
datasets: {
|
||||
bar: {
|
||||
maxBarThickness: 32,
|
||||
borderRadius: 4,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: colorTextLight.value,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: colorForeGroundBase.value,
|
||||
},
|
||||
stacked: true,
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
maxTicksLimit: 3,
|
||||
color: colorTextLight.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
overrides,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
||||
import { useInsightsStore } from '@/features/insights/insights.store';
|
||||
import type { InsightsSummaryType } from '@n8n/api-types';
|
||||
import { computed, defineAsyncComponent, watch } from 'vue';
|
||||
import { useRoute, type LocationQuery } from 'vue-router';
|
||||
|
||||
const InsightsPaywall = defineAsyncComponent(
|
||||
async () => await import('@/features/insights/components/InsightsPaywall.vue'),
|
||||
);
|
||||
const InsightsChartTotal = defineAsyncComponent(
|
||||
async () => await import('@/features/insights/components/charts/InsightsChartTotal.vue'),
|
||||
);
|
||||
const InsightsChartFailed = defineAsyncComponent(
|
||||
async () => await import('@/features/insights/components/charts/InsightsChartFailed.vue'),
|
||||
);
|
||||
const InsightsChartFailureRate = defineAsyncComponent(
|
||||
async () => await import('@/features/insights/components/charts/InsightsChartFailureRate.vue'),
|
||||
);
|
||||
const InsightsChartTimeSaved = defineAsyncComponent(
|
||||
async () => await import('@/features/insights/components/charts/InsightsChartTimeSaved.vue'),
|
||||
);
|
||||
const InsightsChartAverageRuntime = defineAsyncComponent(
|
||||
async () => await import('@/features/insights/components/charts/InsightsChartAverageRuntime.vue'),
|
||||
);
|
||||
const InsightsTableWorkflows = defineAsyncComponent(
|
||||
async () => await import('@/features/insights/components/tables/InsightsTableWorkflows.vue'),
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
insightType: InsightsSummaryType;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const i18n = useI18n();
|
||||
|
||||
const insightsStore = useInsightsStore();
|
||||
|
||||
const chartComponents = computed(() => ({
|
||||
total: InsightsChartTotal,
|
||||
failed: InsightsChartFailed,
|
||||
failureRate: InsightsChartFailureRate,
|
||||
timeSaved: InsightsChartTimeSaved,
|
||||
averageRunTime: InsightsChartAverageRuntime,
|
||||
}));
|
||||
|
||||
type Filter = { time_span: string };
|
||||
const getDefaultFilter = (query: LocationQuery): Filter => {
|
||||
const { time_span } = query as Filter;
|
||||
return {
|
||||
time_span: time_span ?? '7',
|
||||
};
|
||||
};
|
||||
const filters = computed(() => getDefaultFilter(route.query));
|
||||
|
||||
const transformFilter = ({ id, desc }: { id: string; desc: boolean }) => {
|
||||
// TODO: remove exclude once failureRate is added to the BE
|
||||
const key = id as Exclude<InsightsSummaryType, 'failureRate'>;
|
||||
const order = desc ? 'desc' : 'asc';
|
||||
return `${key}:${order}` as const;
|
||||
};
|
||||
|
||||
const fetchPaginatedTableData = ({
|
||||
page,
|
||||
itemsPerPage,
|
||||
sortBy,
|
||||
}: {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
sortBy: Array<{ id: string; desc: boolean }>;
|
||||
}) => {
|
||||
const skip = page * itemsPerPage;
|
||||
const take = itemsPerPage;
|
||||
|
||||
const sortKey = sortBy.length ? transformFilter(sortBy[0]) : undefined;
|
||||
|
||||
void insightsStore.table.execute(0, {
|
||||
skip,
|
||||
take,
|
||||
sortBy: sortKey,
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => filters.value.time_span,
|
||||
() => {
|
||||
if (insightsStore.isSummaryEnabled) {
|
||||
void insightsStore.summary.execute();
|
||||
}
|
||||
|
||||
void insightsStore.charts.execute();
|
||||
void insightsStore.table.execute();
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.insightsView">
|
||||
<N8nHeading bold tag="h2" size="xlarge">{{
|
||||
i18n.baseText('insights.dashboard.title')
|
||||
}}</N8nHeading>
|
||||
<div>
|
||||
<InsightsSummary
|
||||
v-if="insightsStore.isSummaryEnabled"
|
||||
:summary="insightsStore.summary.state"
|
||||
:loading="insightsStore.summary.isLoading"
|
||||
:class="$style.insightsBanner"
|
||||
/>
|
||||
<div v-if="insightsStore.isInsightsEnabled" :class="$style.insightsContent">
|
||||
<div :class="$style.insightsChartWrapper">
|
||||
<template v-if="insightsStore.charts.isLoading"> loading </template>
|
||||
<component
|
||||
:is="chartComponents[props.insightType]"
|
||||
v-else
|
||||
:type="props.insightType"
|
||||
:data="insightsStore.charts.state"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.insightsTableWrapper">
|
||||
<InsightsTableWorkflows
|
||||
:data="insightsStore.table.state"
|
||||
:loading="insightsStore.table.isLoading"
|
||||
@update:options="fetchPaginatedTableData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<InsightsPaywall v-else data-test-id="insights-dashboard-unlicensed" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.insightsView {
|
||||
padding: var(--spacing-l) var(--spacing-2xl);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
overflow: auto;
|
||||
max-width: var(--content-container-width);
|
||||
}
|
||||
|
||||
.insightsBanner {
|
||||
padding-bottom: 0;
|
||||
|
||||
ul {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.insightsContent {
|
||||
padding: var(--spacing-l) 0;
|
||||
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||
border-top: 0;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
background: var(--color-background-xlight);
|
||||
}
|
||||
|
||||
.insightsChartWrapper {
|
||||
height: 292px;
|
||||
padding: 0 var(--spacing-l);
|
||||
}
|
||||
|
||||
.insightsTableWrapper {
|
||||
padding: var(--spacing-l) var(--spacing-l) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.callout">
|
||||
<N8nIcon icon="lock" size="size"></N8nIcon>
|
||||
<N8nText bold tag="h3" size="large">Upgrade to Pro or Enterprise to see full data</N8nText>
|
||||
<N8nText
|
||||
>Gain access to detailed execution data with one year data retention.
|
||||
<N8nLink to="/">Learn more</N8nLink>
|
||||
</N8nText>
|
||||
<N8nButton type="primary" size="large">Upgrade</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.callout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,25 @@
|
||||
import { reactive } from 'vue';
|
||||
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
|
||||
|
||||
const renderComponent = createComponentRenderer(InsightsSummary);
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({}),
|
||||
useRoute: () => reactive({}),
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(InsightsSummary, {
|
||||
global: {
|
||||
stubs: {
|
||||
'router-link': {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('InsightsSummary', () => {
|
||||
it('should render without error', () => {
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { InsightsSummary } from '@n8n/api-types';
|
||||
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
|
||||
import { VIEWS } from '@/constants';
|
||||
import {
|
||||
INSIGHT_IMPACT_TYPES,
|
||||
INSIGHTS_UNIT_IMPACT_MAPPING,
|
||||
} from '@/features/insights/insights.constants';
|
||||
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
|
||||
import type { InsightsSummary } from '@n8n/api-types';
|
||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
summary: InsightsSummaryDisplay;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const route = useRoute();
|
||||
const $style = useCssModule();
|
||||
|
||||
const summaryTitles = computed<Record<keyof InsightsSummary, string>>(() => ({
|
||||
@@ -25,6 +28,13 @@ const summaryTitles = computed<Record<keyof InsightsSummary, string>>(() => ({
|
||||
averageRunTime: i18n.baseText('insights.banner.title.averageRunTime'),
|
||||
}));
|
||||
|
||||
const summaryWithRouteLocations = computed(() =>
|
||||
props.summary.map((s) => ({
|
||||
...s,
|
||||
to: { name: VIEWS.INSIGHTS, params: { insightType: s.id }, query: route.query },
|
||||
})),
|
||||
);
|
||||
|
||||
const getSign = (n: number) => (n > 0 ? '+' : undefined);
|
||||
const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
|
||||
const impact = INSIGHTS_UNIT_IMPACT_MAPPING[id];
|
||||
@@ -42,18 +52,18 @@ const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="summary.length" :class="$style.insights">
|
||||
<div :class="$style.insights">
|
||||
<N8nHeading bold tag="h3" size="small" color="text-light" class="mb-xs">{{
|
||||
i18n.baseText('insights.banner.title', { interpolate: { count: 7 } })
|
||||
}}</N8nHeading>
|
||||
<N8nLoading v-if="loading" :class="$style.loading" :cols="5" />
|
||||
<ul v-else data-test-id="insights-summary-tabs">
|
||||
<li
|
||||
v-for="{ id, value, deviation, unit } in summary"
|
||||
v-for="{ id, value, deviation, unit, to } in summaryWithRouteLocations"
|
||||
:key="id"
|
||||
:data-test-id="`insights-summary-tab-${id}`"
|
||||
>
|
||||
<p>
|
||||
<router-link :to="to" :exact-active-class="$style.activeTab">
|
||||
<strong>{{ summaryTitles[id] }}</strong>
|
||||
<span v-if="value === 0 && id === 'timeSaved'" :class="$style.empty">
|
||||
<em>--</em>
|
||||
@@ -84,7 +94,7 @@ const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
|
||||
{{ getSign(deviation) }}{{ smartDecimal(deviation) }}
|
||||
</small>
|
||||
</span>
|
||||
</p>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -100,28 +110,42 @@ const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
|
||||
display: flex;
|
||||
height: 91px;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-evenly;
|
||||
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||
border-radius: 6px;
|
||||
list-style: none;
|
||||
background-color: var(--color-background-xlight);
|
||||
overflow-x: auto;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
flex: 1 0;
|
||||
border-left: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||
padding: 0 var(--spacing-xl) 0 var(--spacing-l);
|
||||
|
||||
&:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
a {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
border-bottom: 3px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-xlight);
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
&.activeTab {
|
||||
background-color: var(--color-background-xlight);
|
||||
border-color: var(--color-primary);
|
||||
transition: background-color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--color-text-dark);
|
||||
@@ -213,6 +237,7 @@ const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
min-height: 91px;
|
||||
align-self: stretch;
|
||||
align-items: stretch;
|
||||
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`InsightsSummary > should render the summary correctly 1`] = `"<!--v-if-->"`;
|
||||
exports[`InsightsSummary > should render the summary correctly 1`] = `
|
||||
"<div class="insights">
|
||||
<h3 class="n8n-heading text-light size-small bold mb-xs mb-xs">Production executions for the last 7 days</h3>
|
||||
<ul data-test-id="insights-summary-tabs"></ul>
|
||||
</div>"
|
||||
`;
|
||||
|
||||
exports[`InsightsSummary > should render the summary correctly 2`] = `
|
||||
"<div class="insights">
|
||||
<h3 class="n8n-heading text-light size-small bold mb-xs mb-xs">Production executions for the last 7 days</h3>
|
||||
<ul data-test-id="insights-summary-tabs">
|
||||
<li data-test-id="insights-summary-tab-total">
|
||||
<p><strong>Total</strong><span><em>525 <i></i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +85</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-failed">
|
||||
<p><strong>Failed</strong><span><em>14 <i></i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +3</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-failureRate">
|
||||
<p><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.8</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-timeSaved">
|
||||
<p><strong>Time saved</strong><span><em>55.56 <i>h</i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -5.16</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-averageRunTime">
|
||||
<p><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.5</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-total"><a to="[object Object]" exact-active-class="activeTab"><strong>Total</strong><span><em>525 <i></i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +85</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-failed"><a to="[object Object]" exact-active-class="activeTab"><strong>Failed</strong><span><em>14 <i></i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +3</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-failureRate"><a to="[object Object]" exact-active-class="activeTab"><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.8</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-timeSaved"><a to="[object Object]" exact-active-class="activeTab"><strong>Time saved</strong><span><em>55.56 <i>h</i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -5.16</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-averageRunTime"><a to="[object Object]" exact-active-class="activeTab"><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.5</small></span></a></li>
|
||||
</ul>
|
||||
</div>"
|
||||
`;
|
||||
@@ -29,24 +24,14 @@ exports[`InsightsSummary > should render the summary correctly 3`] = `
|
||||
"<div class="insights">
|
||||
<h3 class="n8n-heading text-light size-small bold mb-xs mb-xs">Production executions for the last 7 days</h3>
|
||||
<ul data-test-id="insights-summary-tabs">
|
||||
<li data-test-id="insights-summary-tab-total">
|
||||
<p><strong>Total</strong><span><em>525 <i></i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +85</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-failed">
|
||||
<p><strong>Failed</strong><span><em>14 <i></i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +3</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-failureRate">
|
||||
<p><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.8</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-timeSaved">
|
||||
<p><strong>Time saved</strong><span class="empty"><em>--</em><small><span class="n8n-text compact size-medium regular n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger"><svg class="svg-inline--fa fa-info-circle fa-w-16 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="info-circle" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"></path></svg></span>
|
||||
<li data-test-id="insights-summary-tab-total"><a to="[object Object]" exact-active-class="activeTab"><strong>Total</strong><span><em>525 <i></i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +85</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-failed"><a to="[object Object]" exact-active-class="activeTab"><strong>Failed</strong><span><em>14 <i></i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +3</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-failureRate"><a to="[object Object]" exact-active-class="activeTab"><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.8</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-timeSaved"><a to="[object Object]" exact-active-class="activeTab"><strong>Time saved</strong><span class="empty"><em>--</em><small><span class="n8n-text compact size-medium regular n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger"><svg class="svg-inline--fa fa-info-circle fa-w-16 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="info-circle" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"></path></svg></span>
|
||||
<!--teleport start-->
|
||||
<!--teleport end--></small></span>
|
||||
</p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-averageRunTime">
|
||||
<p><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.5</small></span></p>
|
||||
</li>
|
||||
</a></li>
|
||||
<li data-test-id="insights-summary-tab-averageRunTime"><a to="[object Object]" exact-active-class="activeTab"><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.5</small></span></a></li>
|
||||
</ul>
|
||||
</div>"
|
||||
`;
|
||||
@@ -55,21 +40,11 @@ exports[`InsightsSummary > should render the summary correctly 4`] = `
|
||||
"<div class="insights">
|
||||
<h3 class="n8n-heading text-light size-small bold mb-xs mb-xs">Production executions for the last 7 days</h3>
|
||||
<ul data-test-id="insights-summary-tabs">
|
||||
<li data-test-id="insights-summary-tab-total">
|
||||
<p><strong>Total</strong><span><em>525 <i></i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -2</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-failed">
|
||||
<p><strong>Failed</strong><span><em>14 <i></i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -3</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-failureRate">
|
||||
<p><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +0.8</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-timeSaved">
|
||||
<p><strong>Time saved</strong><span><em>55.56 <i>h</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-right fa-w-6 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-right" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path class="" fill="currentColor" d="M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z"></path></svg></span> 0</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-averageRunTime">
|
||||
<p><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +0.5</small></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-total"><a to="[object Object]" exact-active-class="activeTab"><strong>Total</strong><span><em>525 <i></i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -2</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-failed"><a to="[object Object]" exact-active-class="activeTab"><strong>Failed</strong><span><em>14 <i></i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -3</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-failureRate"><a to="[object Object]" exact-active-class="activeTab"><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +0.8</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-timeSaved"><a to="[object Object]" exact-active-class="activeTab"><strong>Time saved</strong><span><em>55.56 <i>h</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-right fa-w-6 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-right" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path class="" fill="currentColor" d="M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z"></path></svg></span> 0</small></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-averageRunTime"><a to="[object Object]" exact-active-class="activeTab"><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +0.5</small></span></a></li>
|
||||
</ul>
|
||||
</div>"
|
||||
`;
|
||||
@@ -78,21 +53,11 @@ exports[`InsightsSummary > should render the summary correctly 5`] = `
|
||||
"<div class="insights">
|
||||
<h3 class="n8n-heading text-light size-small bold mb-xs mb-xs">Production executions for the last 7 days</h3>
|
||||
<ul data-test-id="insights-summary-tabs">
|
||||
<li data-test-id="insights-summary-tab-total">
|
||||
<p><strong>Total</strong><span><em>525 <i></i></em><!--v-if--></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-failed">
|
||||
<p><strong>Failed</strong><span><em>14 <i></i></em><!--v-if--></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-failureRate">
|
||||
<p><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><!--v-if--></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-timeSaved">
|
||||
<p><strong>Time saved</strong><span><em>55.56 <i>h</i></em><!--v-if--></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-averageRunTime">
|
||||
<p><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><!--v-if--></span></p>
|
||||
</li>
|
||||
<li data-test-id="insights-summary-tab-total"><a to="[object Object]" exact-active-class="activeTab"><strong>Total</strong><span><em>525 <i></i></em><!--v-if--></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-failed"><a to="[object Object]" exact-active-class="activeTab"><strong>Failed</strong><span><em>14 <i></i></em><!--v-if--></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-failureRate"><a to="[object Object]" exact-active-class="activeTab"><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><!--v-if--></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-timeSaved"><a to="[object Object]" exact-active-class="activeTab"><strong>Time saved</strong><span><em>55.56 <i>h</i></em><!--v-if--></span></a></li>
|
||||
<li data-test-id="insights-summary-tab-averageRunTime"><a to="[object Object]" exact-active-class="activeTab"><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><!--v-if--></span></a></li>
|
||||
</ul>
|
||||
</div>"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import { type ScriptableContext, type ChartData, Filler } from 'chart.js';
|
||||
import dateformat from 'dateformat';
|
||||
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
|
||||
import {
|
||||
generateLinearGradient,
|
||||
generateLineChartOptions,
|
||||
} from '@/features/insights/chartjs.utils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { transformInsightsAverageRunTime } from '@/features/insights/insights.utils';
|
||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||
import { INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
|
||||
|
||||
const props = defineProps<{
|
||||
data: InsightsByTime[];
|
||||
type: InsightsSummaryType;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const chartOptions = computed(() =>
|
||||
generateLineChartOptions({
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.dataset.label ?? '';
|
||||
return `${label} ${smartDecimal(context.parsed.y)}${INSIGHTS_UNIT_MAPPING[props.type]}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const chartData = computed<ChartData<'line'>>(() => {
|
||||
const labels: string[] = [];
|
||||
const data: number[] = [];
|
||||
|
||||
for (const entry of props.data) {
|
||||
labels.push(dateformat(entry.date, 'd. mmm'));
|
||||
|
||||
const value = transformInsightsAverageRunTime(entry.values.averageRunTime);
|
||||
|
||||
data.push(value);
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.baseText('insights.banner.title.averageRunTime'),
|
||||
data,
|
||||
cubicInterpolationMode: 'monotone' as const,
|
||||
fill: 'origin',
|
||||
backgroundColor: (ctx: ScriptableContext<'line'>) =>
|
||||
generateLinearGradient(ctx.chart.ctx, 292),
|
||||
borderColor: 'rgba(255, 64, 39, 1)',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Line :data="chartData" :options="chartOptions" :plugins="[Filler]" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Bar } from 'vue-chartjs';
|
||||
import type { ChartData } from 'chart.js';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
import dateformat from 'dateformat';
|
||||
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
|
||||
import { generateBarChartOptions } from '@/features/insights/chartjs.utils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||
|
||||
const props = defineProps<{
|
||||
data: InsightsByTime[];
|
||||
type: InsightsSummaryType;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const colorPrimary = useCssVar('--color-primary', document.body);
|
||||
const chartOptions = computed(() =>
|
||||
generateBarChartOptions({
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.dataset.label ?? '';
|
||||
return `${label} ${smartDecimal(context.parsed.y)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const chartData = computed<ChartData<'bar'>>(() => {
|
||||
const labels: string[] = [];
|
||||
const data: number[] = [];
|
||||
|
||||
for (const entry of props.data) {
|
||||
labels.push(dateformat(entry.date, 'd. mmm'));
|
||||
data.push(entry.values.failed);
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.baseText('insights.banner.title.failed'),
|
||||
data,
|
||||
backgroundColor: colorPrimary.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Bar } from 'vue-chartjs';
|
||||
import type { ChartData } from 'chart.js';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
import dateformat from 'dateformat';
|
||||
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
|
||||
import { generateBarChartOptions } from '@/features/insights/chartjs.utils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { transformInsightsFailureRate } from '@/features/insights/insights.utils';
|
||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||
import { INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
|
||||
|
||||
const props = defineProps<{
|
||||
data: InsightsByTime[];
|
||||
type: InsightsSummaryType;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const colorPrimary = useCssVar('--color-primary', document.body);
|
||||
const chartOptions = computed(() =>
|
||||
generateBarChartOptions({
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.dataset.label ?? '';
|
||||
return `${label} ${smartDecimal(context.parsed.y)}${INSIGHTS_UNIT_MAPPING[props.type]}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const chartData = computed<ChartData<'bar'>>(() => {
|
||||
const labels: string[] = [];
|
||||
const data: number[] = [];
|
||||
|
||||
for (const entry of props.data) {
|
||||
labels.push(dateformat(entry.date, 'd. mmm'));
|
||||
data.push(transformInsightsFailureRate(entry.values.failureRate));
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.baseText('insights.banner.title.failureRate'),
|
||||
data,
|
||||
backgroundColor: colorPrimary.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import { type ScriptableContext, type ChartData, Filler } from 'chart.js';
|
||||
import dateformat from 'dateformat';
|
||||
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
|
||||
import {
|
||||
generateLinearGradient,
|
||||
generateLineChartOptions,
|
||||
} from '@/features/insights/chartjs.utils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { transformInsightsTimeSaved } from '@/features/insights/insights.utils';
|
||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||
import { INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
|
||||
|
||||
const props = defineProps<{
|
||||
data: InsightsByTime[];
|
||||
type: InsightsSummaryType;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const chartOptions = computed(() =>
|
||||
generateLineChartOptions({
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.dataset.label ?? '';
|
||||
return `${label} ${smartDecimal(context.parsed.y)}${INSIGHTS_UNIT_MAPPING[props.type]}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const chartData = computed<ChartData<'line'>>(() => {
|
||||
const labels: string[] = [];
|
||||
const data: number[] = [];
|
||||
|
||||
for (const entry of props.data) {
|
||||
labels.push(dateformat(entry.date, 'd. mmm'));
|
||||
const timeSaved = transformInsightsTimeSaved(entry.values.timeSaved);
|
||||
data.push(timeSaved);
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.baseText('insights.banner.title.timeSaved'),
|
||||
data,
|
||||
fill: 'origin',
|
||||
cubicInterpolationMode: 'monotone' as const,
|
||||
backgroundColor: (ctx: ScriptableContext<'line'>) =>
|
||||
generateLinearGradient(ctx.chart.ctx, 292),
|
||||
borderColor: 'rgba(255, 64, 39, 1)',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Line :data="chartData" :options="chartOptions" :plugins="[Filler]" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Bar } from 'vue-chartjs';
|
||||
import type { ChartData } from 'chart.js';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
import dateformat from 'dateformat';
|
||||
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
|
||||
import { generateBarChartOptions } from '@/features/insights/chartjs.utils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const props = defineProps<{
|
||||
data: InsightsByTime[];
|
||||
type: InsightsSummaryType;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const colorPrimary = useCssVar('--color-primary', document.body);
|
||||
const chartOptions = computed(() =>
|
||||
generateBarChartOptions({
|
||||
plugins: {
|
||||
tooltip: {
|
||||
itemSort: (a) =>
|
||||
a.dataset.label === i18n.baseText('insights.banner.title.succeeded') ? -1 : 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const chartData = computed<ChartData<'bar'>>(() => {
|
||||
const labels: string[] = [];
|
||||
const succeededData: number[] = [];
|
||||
const failedData: number[] = [];
|
||||
|
||||
for (const entry of props.data) {
|
||||
labels.push(dateformat(entry.date, 'd. mmm'));
|
||||
succeededData.push(entry.values.succeeded);
|
||||
failedData.push(entry.values.failed);
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: i18n.baseText('insights.banner.title.failed'),
|
||||
data: failedData,
|
||||
backgroundColor: colorPrimary.value,
|
||||
},
|
||||
{
|
||||
label: i18n.baseText('insights.banner.title.succeeded'),
|
||||
data: succeededData,
|
||||
backgroundColor: '#3E999F',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts" setup="">
|
||||
import { INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
|
||||
import {
|
||||
transformInsightsAverageRunTime,
|
||||
transformInsightsFailureRate,
|
||||
transformInsightsTimeSaved,
|
||||
} from '@/features/insights/insights.utils';
|
||||
import type { InsightsByWorkflow } from '@n8n/api-types';
|
||||
import { N8nTooltip } from '@n8n/design-system';
|
||||
import N8nDataTableServer, {
|
||||
type TableHeader,
|
||||
} from '@n8n/design-system/components/N8nDataTableServer/N8nDataTableServer.vue';
|
||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
data: InsightsByWorkflow;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
type Item = InsightsByWorkflow['data'][number];
|
||||
|
||||
const rows = computed(() => props.data.data);
|
||||
|
||||
const headers = ref<Array<TableHeader<Item>>>([
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'workflowName',
|
||||
width: 400,
|
||||
disableSort: true,
|
||||
},
|
||||
{
|
||||
title: 'Total executions',
|
||||
key: 'total',
|
||||
},
|
||||
{
|
||||
title: 'Total failed executions',
|
||||
key: 'failed',
|
||||
},
|
||||
{
|
||||
title: 'Average run time',
|
||||
key: 'failureRate',
|
||||
value(row) {
|
||||
return (
|
||||
smartDecimal(transformInsightsFailureRate(row.failureRate)) +
|
||||
INSIGHTS_UNIT_MAPPING.failureRate
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Time saved',
|
||||
key: 'timeSaved',
|
||||
value(row) {
|
||||
return (
|
||||
smartDecimal(transformInsightsTimeSaved(row.timeSaved)) + INSIGHTS_UNIT_MAPPING.timeSaved
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Run time',
|
||||
key: 'averageRunTime',
|
||||
value(row) {
|
||||
return (
|
||||
smartDecimal(transformInsightsAverageRunTime(row.averageRunTime)) +
|
||||
INSIGHTS_UNIT_MAPPING.averageRunTime
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Project name',
|
||||
key: 'projectName',
|
||||
disableSort: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const sortTableBy = ref([{ id: 'total', desc: true }]);
|
||||
const currentPage = ref(0);
|
||||
const itemsPerPage = ref(20);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:options': [
|
||||
payload: {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
sortBy: Array<{ id: string; desc: boolean }>;
|
||||
},
|
||||
];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<N8nHeading bold tag="h3" size="medium" class="mb-s">Workflow insights</N8nHeading>
|
||||
<N8nDataTableServer
|
||||
v-model:sort-by="sortTableBy"
|
||||
v-model:page="currentPage"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
:items="rows"
|
||||
:headers="headers"
|
||||
:items-length="data.count"
|
||||
:loading="loading"
|
||||
@update:options="emit('update:options', $event)"
|
||||
>
|
||||
<template #[`item.workflowName`]="{ item }">
|
||||
<N8nTooltip :content="item.workflowName" placement="top">
|
||||
<div class="ellipsis">
|
||||
{{ item.workflowName }}
|
||||
</div>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
<template #[`item.projectName`]="{ item }">
|
||||
<N8nTooltip v-if="item.projectName" :content="item.projectName" placement="top">
|
||||
<div class="ellipsis">
|
||||
{{ item.projectName }}
|
||||
</div>
|
||||
</N8nTooltip>
|
||||
<template v-else> - </template>
|
||||
</template>
|
||||
</N8nDataTableServer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ellipsis {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { IRestApiContext } from '@/Interface';
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
import type { InsightsSummary } from '@n8n/api-types';
|
||||
import type { IRestApiContext } from '@/Interface';
|
||||
import type {
|
||||
InsightsSummary,
|
||||
InsightsByTime,
|
||||
InsightsByWorkflow,
|
||||
ListInsightsWorkflowQueryDto,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
export const fetchInsightsSummary = async (context: IRestApiContext): Promise<InsightsSummary> =>
|
||||
await makeRestApiRequest<InsightsSummary>(context, 'GET', '/insights/summary');
|
||||
await makeRestApiRequest(context, 'GET', '/insights/summary');
|
||||
|
||||
export const fetchInsightsByTime = async (context: IRestApiContext): Promise<InsightsByTime[]> =>
|
||||
await makeRestApiRequest(context, 'GET', '/insights/by-time');
|
||||
|
||||
export const fetchInsightsByWorkflow = async (
|
||||
context: IRestApiContext,
|
||||
filter?: ListInsightsWorkflowQueryDto,
|
||||
): Promise<InsightsByWorkflow> =>
|
||||
await makeRestApiRequest(context, 'GET', '/insights/by-workflow', filter);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RouterView, type RouteRecordRaw } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||
const InsightsDashboard = async () =>
|
||||
await import('@/features/insights/components/InsightsDashboard.vue');
|
||||
|
||||
export const insightsRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/insights',
|
||||
components: {
|
||||
default: RouterView,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'rbac'],
|
||||
middlewareOptions: {
|
||||
rbac: {
|
||||
scope: ['insights:list'],
|
||||
},
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: ':insightType?',
|
||||
name: VIEWS.INSIGHTS,
|
||||
beforeEnter(to) {
|
||||
if (to.params.insightType) return true;
|
||||
return Object.assign(to, { params: { ...to.params, insightType: 'total' } });
|
||||
},
|
||||
component: InsightsDashboard,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,26 +1,31 @@
|
||||
import { computed } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAsyncState } from '@vueuse/core';
|
||||
import type { ListInsightsWorkflowQueryDto } from '@n8n/api-types';
|
||||
import * as insightsApi from '@/features/insights/insights.api';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { transformInsightsSummary } from '@/features/insights/insights.utils';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
export const useInsightsStore = defineStore('insights', () => {
|
||||
const rootStore = useRootStore();
|
||||
const usersStore = useUsersStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const globalInsightsPermissions = computed(
|
||||
() => getResourcePermissions(usersStore.currentUser?.globalScopes).insights,
|
||||
);
|
||||
|
||||
const isInsightsEnabled = computed(() => settingsStore.settings.insights.enabled);
|
||||
|
||||
const isSummaryEnabled = computed(
|
||||
() => globalInsightsPermissions.value.list && isInsightsEnabled.value,
|
||||
);
|
||||
|
||||
const summary = useAsyncState(
|
||||
async () => {
|
||||
if (!globalInsightsPermissions.value.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const raw = await insightsApi.fetchInsightsSummary(rootStore.restApiContext);
|
||||
return transformInsightsSummary(raw);
|
||||
},
|
||||
@@ -28,8 +33,31 @@ export const useInsightsStore = defineStore('insights', () => {
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
const charts = useAsyncState(
|
||||
async () => {
|
||||
return await insightsApi.fetchInsightsByTime(rootStore.restApiContext);
|
||||
},
|
||||
[],
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
const table = useAsyncState(
|
||||
async (filter?: ListInsightsWorkflowQueryDto) => {
|
||||
return await insightsApi.fetchInsightsByWorkflow(rootStore.restApiContext, filter);
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
data: [],
|
||||
},
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
return {
|
||||
summary,
|
||||
globalInsightsPermissions,
|
||||
isInsightsEnabled,
|
||||
isSummaryEnabled,
|
||||
summary,
|
||||
charts,
|
||||
table,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,10 +5,16 @@ import {
|
||||
INSIGHTS_UNIT_MAPPING,
|
||||
} from '@/features/insights/insights.constants';
|
||||
|
||||
const transformInsightsValues: Partial<Record<InsightsSummaryType, (value: number) => number>> = {
|
||||
timeSaved: (value: number): number => value / 3600, // we want to show saved time in hours
|
||||
averageRunTime: (value: number): number => Math.round((value / 1000) * 100) / 100, // we want to show average run time in seconds with 2 decimal places
|
||||
failureRate: (value: number): number => value * 100, // we want to show failure rate in percentage
|
||||
export const transformInsightsTimeSaved = (value: number): number => value / 3600; // we want to show saved time in hours
|
||||
export const transformInsightsAverageRunTime = (value: number): number => value / 1000; // we want to show average run time in seconds
|
||||
export const transformInsightsFailureRate = (value: number): number => value * 100; // we want to show failure rate in percentage
|
||||
|
||||
export const transformInsightsValues: Partial<
|
||||
Record<InsightsSummaryType, (value: number) => number>
|
||||
> = {
|
||||
timeSaved: transformInsightsTimeSaved,
|
||||
averageRunTime: transformInsightsAverageRunTime,
|
||||
failureRate: transformInsightsFailureRate,
|
||||
};
|
||||
|
||||
export const transformInsightsSummary = (data: InsightsSummary | null): InsightsSummaryDisplay =>
|
||||
|
||||
@@ -100,12 +100,16 @@ export async function initializeAuthenticatedFeatures(
|
||||
console.error('Failed to initialize cloud plan store:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (insightsStore.isSummaryEnabled) {
|
||||
void insightsStore.summary.execute();
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
projectsStore.getMyProjects(),
|
||||
projectsStore.getPersonalProject(),
|
||||
projectsStore.getProjectsCount(),
|
||||
rolesStore.fetchRoles(),
|
||||
insightsStore.summary.execute(),
|
||||
]);
|
||||
|
||||
authenticatedFeaturesInitialized = true;
|
||||
|
||||
@@ -3074,7 +3074,10 @@
|
||||
"insights.banner.timeSaved.tooltip.link.text": "add time estimates",
|
||||
"insights.banner.title.total": "Total",
|
||||
"insights.banner.title.failed": "Failed",
|
||||
"insights.banner.title.succeeded": "Succeeded",
|
||||
"insights.banner.title.failureRate": "Failure rate",
|
||||
"insights.banner.title.timeSaved": "Time saved",
|
||||
"insights.banner.title.averageRunTime": "Avg. run time"
|
||||
"insights.banner.title.timeSavedDailyAverage": "Time saved daily avg.",
|
||||
"insights.banner.title.averageRunTime": "Avg. run time",
|
||||
"insights.dashboard.title": "Insights"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { RouterMiddleware } from '@/types/router';
|
||||
import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
|
||||
import { tryToParseNumber } from '@/utils/typesUtils';
|
||||
import { projectsRoutes } from '@/routes/projects.routes';
|
||||
import { insightsRoutes } from '@/features/insights/insights.router';
|
||||
import TestDefinitionRunDetailView from './views/TestDefinition/TestDefinitionRunDetailView.vue';
|
||||
|
||||
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
|
||||
@@ -734,6 +735,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
},
|
||||
...projectsRoutes,
|
||||
...insightsRoutes,
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: VIEWS.NOT_FOUND,
|
||||
|
||||
@@ -242,7 +242,7 @@ onMounted(() => {
|
||||
<template #header>
|
||||
<ProjectHeader>
|
||||
<InsightsSummary
|
||||
v-if="overview.isOverviewSubPage"
|
||||
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
|
||||
:loading="insightsStore.summary.isLoading"
|
||||
:summary="insightsStore.summary.state"
|
||||
/>
|
||||
|
||||
@@ -96,7 +96,7 @@ async function onExecutionStop() {
|
||||
>
|
||||
<ProjectHeader>
|
||||
<InsightsSummary
|
||||
v-if="overview.isOverviewSubPage"
|
||||
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
|
||||
:loading="insightsStore.summary.isLoading"
|
||||
:summary="insightsStore.summary.state"
|
||||
/>
|
||||
|
||||
@@ -1225,7 +1225,7 @@ const onCreateWorkflowClick = () => {
|
||||
<template #header>
|
||||
<ProjectHeader @create-folder="createFolderInCurrent">
|
||||
<InsightsSummary
|
||||
v-if="overview.isOverviewSubPage"
|
||||
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
|
||||
:loading="insightsStore.summary.isLoading"
|
||||
:summary="insightsStore.summary.state"
|
||||
/>
|
||||
|
||||
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@@ -1499,6 +1499,9 @@ importers:
|
||||
'@n8n/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../../@n8n/utils
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.2
|
||||
version: 8.21.2(vue@3.5.13(typescript@5.8.2))
|
||||
'@vueuse/core':
|
||||
specifier: '*'
|
||||
version: 10.11.0(vue@3.5.13(typescript@5.8.2))
|
||||
@@ -5773,6 +5776,16 @@ packages:
|
||||
'@sxzz/popperjs-es@2.11.7':
|
||||
resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
|
||||
|
||||
'@tanstack/table-core@8.21.2':
|
||||
resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/vue-table@8.21.2':
|
||||
resolution: {integrity: sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vue: '>=3.2'
|
||||
|
||||
'@techteamer/ocsp@1.0.1':
|
||||
resolution: {integrity: sha512-q4pW5wAC6Pc3JI8UePwE37CkLQ5gDGZMgjSX4MEEm4D4Di59auDQ8UNIDzC4gRnPNmmcwjpPxozq8p5pjiOmOw==}
|
||||
|
||||
@@ -18542,6 +18555,13 @@ snapshots:
|
||||
|
||||
'@sxzz/popperjs-es@2.11.7': {}
|
||||
|
||||
'@tanstack/table-core@8.21.2': {}
|
||||
|
||||
'@tanstack/vue-table@8.21.2(vue@3.5.13(typescript@5.8.2))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.2
|
||||
vue: 3.5.13(typescript@5.8.2)
|
||||
|
||||
'@techteamer/ocsp@1.0.1':
|
||||
dependencies:
|
||||
asn1.js: 5.4.1
|
||||
@@ -21605,7 +21625,7 @@ snapshots:
|
||||
|
||||
eslint-import-resolver-node@0.3.9:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
is-core-module: 2.13.1
|
||||
resolve: 1.22.8
|
||||
transitivePeerDependencies:
|
||||
@@ -21630,7 +21650,7 @@ snapshots:
|
||||
|
||||
eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.8.2)
|
||||
eslint: 8.57.0
|
||||
@@ -21650,7 +21670,7 @@ snapshots:
|
||||
array.prototype.findlastindex: 1.2.3
|
||||
array.prototype.flat: 1.3.2
|
||||
array.prototype.flatmap: 1.3.2
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.57.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
@@ -22459,7 +22479,7 @@ snapshots:
|
||||
array-parallel: 0.1.3
|
||||
array-series: 0.1.5
|
||||
cross-spawn: 4.0.2
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -25324,7 +25344,7 @@ snapshots:
|
||||
|
||||
pdf-parse@1.1.1:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
node-ensure: 0.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -26167,7 +26187,7 @@ snapshots:
|
||||
|
||||
rhea@1.0.24:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
||||
Reference in New Issue
Block a user