Files
openobserve/web/src/components/functions/FunctionList.vue
T

791 lines
24 KiB
Vue

<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<!-- eslint-disable vue/v-on-event-hyphenation -->
<!-- eslint-disable vue/attribute-hyphenation -->
<template>
<div
data-test="function-list-page"
class="flex flex-col h-full min-h-0"
>
<div v-if="!showAddJSTransformDialog" class="flex flex-col h-full min-h-0">
<!-- Standard section header: title + actions only. Search moved to toolbar. -->
<AppPageHeader
:title="t('function.header')"
icon="function"
:subtitle="t('function.subtitle')"
tabs-below
class="shrink-0 px-4"
>
<template #tabs>
<PipelineSectionTabs />
</template>
<template #actions>
<OButton
variant="primary"
size="sm"
data-test="function-list-add-function-btn"
@click="showAddUpdateFn({})"
>
{{ t(`function.add`) }}
</OButton>
</template>
</AppPageHeader>
<div class="w-full flex-1 min-h-0 overflow-hidden">
<div class="h-full">
<OTable
:frame="false"
:data="visibleRows"
:columns="columns"
row-key="name"
:loading="loading"
pagination="client"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
selection="multiple"
v-model:selected-ids="selectedFunctionIds"
:show-global-filter="false"
:default-columns="false"
width="100%"
class="w-full h-full"
>
<template #toolbar>
<div class="flex items-center gap-2 w-full">
<OSearchInput
data-test="functions-list-search-input"
v-model="filterQuery"
class="flex-1"
:placeholder="t('function.search')"
/>
</div>
</template>
<template #toolbar-trailing>
<OButton
variant="outline"
size="icon-sm"
icon-left="refresh"
:loading="loading"
data-test="functions-list-refresh-btn"
@click="getJSTransforms"
>
<OTooltip side="bottom" :content="t('common.refresh')" shortcut-id="functionsRefresh" />
</OButton>
</template>
<template #empty>
<OEmptyState
size="hero"
preset="no-functions"
:filtered="!!filterQuery"
@action="(id) => (id === 'clear-filters' ? (filterQuery = '') : showAddUpdateFn({}))"
/>
</template>
<template #cell-name="{ row, value }">
<span class="text-text-primary" :data-test="`function-list-name-cell-${row?.name ?? value}`">{{ value }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center actions-container">
<OButton
variant="ghost"
size="icon-sm"
:title="t('function.updateTitle')"
data-test="function-list-edit-function-btn"
data-row-action="edit"
@click="showAddUpdateFn({ row })"
icon-left="edit"
/>
<OButton
variant="ghost-destructive"
size="icon-sm"
:title="t('function.delete')"
data-test="function-list-delete-function-btn"
data-row-action="delete"
@click="showDeleteDialogFn({ row })"
icon-left="delete"
/>
<OButton
variant="ghost"
size="icon-sm"
icon-left="account-tree"
:title="'Associated Pipelines'"
data-row-action="view"
@click="getAssociatedPipelines({ row })"
/>
</div>
</template>
<template #bottom="scope">
<div class="flex items-center justify-between w-full py-2">
<div class="flex items-center o2-table-footer-title mr-4">
{{ resultTotal }} {{ t('function.header') }}
</div>
<OButton
v-if="selectedFunctions.length > 0"
data-test="function-list-delete-functions-btn"
variant="outline-destructive"
size="sm"
@click="openBulkDeleteDialog"
icon-left="delete"
>
Delete
</OButton>
</div>
</template>
</OTable>
</div>
</div>
</div>
<div v-else class="flex-1 min-h-0">
<AddFunction
v-model="formData"
:isUpdated="isUpdated"
class="p-2"
@update:list="refreshList"
@cancel:hideform="hideForm"
@sendToAiChat="sendToAiChat"
/>
</div>
<ConfirmDialog
title="Delete Transform"
message="Are you sure you want to delete transform?"
@update:ok="deleteFn"
@update:cancel="confirmDelete = false"
v-model="confirmDelete"
/>
<ConfirmDialog
title="Delete Functions"
:message="`Are you sure you want to delete ${selectedFunctions.length} function(s)?`"
@update:ok="bulkDeleteFunctions"
@update:cancel="confirmBulkDelete = false"
v-model="confirmBulkDelete"
/>
<ODialog data-test="function-list-force-delete-dialog" v-model:open="confirmForceDelete" persistent size="md"
:title="`Pipelines Associated with ${selectedDelete?.name}`"
>
<div
v-if="transformedPipelineList.length > 0"
class="max-h-50 overflow-y-auto"
>
<ul class="scrollable-list flex flex-col list-none p-0 m-0">
<li
v-for="(pipeline, index) in transformedPipelineList"
:key="pipeline.value"
@click="onPipelineSelect(pipeline)"
class="flex items-center px-3 py-2 cursor-pointer hover:bg-muted/50"
:data-test="`function-list-pipeline-item-${pipeline.value}`"
>
<span class="text-sm">{{ index + 1 }}. {{ pipeline.label }}</span>
</li>
</ul>
</div>
<div v-else>
<div class="text-xl font-semibold text-center">
No pipelines associated with this function
</div>
</div>
</ODialog>
</div>
</template>
<script lang="ts">
import {
defineAsyncComponent,
defineComponent,
ref,
computed,
watch,
onMounted,
} from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { TABLE_INDEX_COL_SIZE } from "@/lib/core/Table/OTable.types";
import jsTransformService from "../../services/jstransform";
import NoData from "../shared/grid/NoData.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import ConfirmDialog from "../ConfirmDialog.vue";
import segment from "../../services/segment_analytics";
import { getImageURL, verifyOrganizationStatus } from "../../utils/zincutils";
import { useReo } from "@/services/reodotdev_analytics";
import searchState from "@/composables/useLogs/searchState";
import OButton from "@/lib/core/Button/OButton.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import AppPageHeader from "@/components/common/AppPageHeader.vue";
import PipelineSectionTabs from "@/components/pipeline/PipelineSectionTabs.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
export default defineComponent({
name: "functionList",
components: {
OEmptyState,
AppPageHeader,
PipelineSectionTabs,
OTable,
AddFunction: defineAsyncComponent(() => import("./AddFunction.vue")),
NoData,
ConfirmDialog,
OButton,
ODialog,
OSearchInput,
OTooltip,
OCheckbox,
},
emits: [
"updated:fields",
"update:changeRecordPerPage",
"update:maxRecordToReturn",
"sendToAiChat"
],
setup(props, { emit }) {
const store = useStore();
const { t } = useI18n();
const router = useRouter();
const jsTransforms: any = ref([]);
const formData: any = ref({});
const showAddJSTransformDialog: any = ref(false);
const selectedDelete: any = ref(null);
const isUpdated: any = ref(false);
const confirmDelete = ref<boolean>(false);
const confirmForceDelete = ref<boolean>(false);
const confirmBulkDelete = ref<boolean>(false);
const { searchObj } = searchState();
const pipelineList = ref([]);
const selectedPipeline = ref("");
const filterQuery = ref("");
const { track } = useReo();
const columns: OTableColumnDef[] = [
{
id: "#",
header: "#",
accessorKey: "#",
sortable: false,
size: TABLE_INDEX_COL_SIZE,
meta: { align: "left" },
},
{
id: "name",
accessorKey: "name",
header: t("common.name"),
sortable: true,
meta: { align: "left", autoWidth: true },
},
{
id: "actions",
header: t("function.actions"),
isAction: true,
size: 150,
meta: { align: "center", cellClass: "actions-column", actionCount: 3 },
},
];
const onPipelineSelect = (pipeline: any) => {
const routeUrl = router.resolve({
name: "pipelineEditor",
query: {
id: pipeline.value,
name: pipeline.label,
org_identifier: store.state.selectedOrganization.identifier,
},
}).href;
window.open(routeUrl, "_blank");
};
const loading = ref(false);
const getJSTransforms = () => {
loading.value = true;
// return ;
const dismiss = toast({
variant: "loading",
message: "Please wait while loading functions...",
timeout: 0,
});
jsTransformService
.list(
1,
100000,
"name",
false,
"",
store.state.selectedOrganization.identifier,
)
.then((res) => {
var counter = 1;
resultTotal.value = res.data.list.length;
if (router.currentRoute.value.query.action == "add") {
showAddUpdateFn({ row: undefined });
}
jsTransforms.value = res.data.list.map((data: any) => {
if (router.currentRoute.value.query.action == "update") {
if (router.currentRoute.value.query.name == data.name) {
showAddUpdateFn({ row: data });
}
}
return {
"#": counter <= 9 ? `0${counter++}` : counter++,
name: data.name,
function: data.function,
params: data.params,
// order: data.order ? data.order : 1,
// stream_name: data.stream_name ? data.stream_name : "--",
// stream_type: data.stream_type ? data.stream_type : "--",
transType: data.transType.toString(),
// ingest: data.stream_name ? true : false,
actions: "",
};
});
searchObj.data.transforms = jsTransforms.value;
dismiss();
})
.catch((err) => {
console.error("Error while pulling function", err);
dismiss();
if (err?.response?.status && err?.response?.status != 403) {
toast({
variant: "error",
message: "Error while pulling function.",
});
}
})
.finally(() => {
loading.value = false;
});
};
if (jsTransforms.value == "" || jsTransforms.value == undefined) {
getJSTransforms();
}
const resultTotal = ref<number>(0);
const pageSize = ref(20);
const pageSizeOptions = [20, 50, 100, 250, 500];
const selectedFunctionIds = ref<string[]>([]);
const selectedFunctions = computed({
get: () =>
(jsTransforms.value || []).filter((row: any) =>
selectedFunctionIds.value.includes(row.name),
),
set: (val) => {
selectedFunctionIds.value = val.map((row: any) => row.name);
},
});
const addTransform = () => {
showAddJSTransformDialog.value = true;
};
const transformedPipelineList = computed(() => {
return pipelineList.value.map((pipeline: any) => ({
label: pipeline.name,
value: pipeline.id,
}));
});
const showAddUpdateFn = (props: any) => {
formData.value = props.row;
let action;
if (!props.row) {
isUpdated.value = false;
action = "Add Function";
router.push({
name: "functionList",
query: {
action: "add",
org_identifier: store.state.selectedOrganization.identifier,
},
});
track("Button Click", {
button: "Add Function",
page: "Functions"
});
} else {
isUpdated.value = true;
action = "Update Function";
router.push({
name: "functionList",
query: {
action: "update",
name: props.row.name,
org_identifier: store.state.selectedOrganization.identifier,
},
});
track("Button Click", {
button: "Update Function",
page: "Functions"
});
}
addTransform();
segment.track("Button Click", {
button: action,
user_org: store.state.selectedOrganization.identifier,
user_id: store.state.userInfo.email,
page: "Functions",
});
};
const refreshList = () => {
router.push({
name: "functionList",
query: {
org_identifier: store.state.selectedOrganization.identifier,
},
});
showAddJSTransformDialog.value = false;
getJSTransforms();
};
const hideForm = () => {
showAddJSTransformDialog.value = false;
router.replace({
name: "functionList",
query: {
org_identifier: store.state.selectedOrganization.identifier,
},
});
};
const deleteFn = () => {
jsTransformService
.delete(
store.state.selectedOrganization.identifier,
selectedDelete.value.name,
)
.then((res: any) => {
if (res.data.code == 200) {
toast({
variant: "success",
message: res.data.message,
});
getJSTransforms();
} else {
toast({
variant: "error",
message: res.data.message,
});
}
})
.catch((err) => {
if (err.response.data.code == 409) {
toast({
variant: "error",
message:
"Function deletion failed as it is associated with pipelines. Click on view button to get associated pipelines.",
timeout: 10000,
action: {
label: "View",
handler: () => {
forceRemoveFunction(err.response.data["message"]);
},
},
});
return;
}
if (err.response.status != 403) {
toast({
variant: "error",
message:
JSON.stringify(err.response.data["message"]) ||
"Function deletion failed.",
});
}
});
segment.track("Button Click", {
button: "Delete Function",
user_org: store.state.selectedOrganization.identifier,
user_id: store.state.userInfo.email,
function_name: selectedDelete.value.name,
is_ingest_func: selectedDelete.value.ingest,
page: "Functions",
});
};
const showDeleteDialogFn = (props: any) => {
selectedDelete.value = props.row;
confirmDelete.value = true;
};
const getAssociatedPipelines = (props: any) => {
selectedDelete.value = props.row;
jsTransformService
.getAssociatedPipelines(
store.state.selectedOrganization.identifier,
props.row.name,
)
.then((res: any) => {
pipelineList.value = res.data.list;
confirmForceDelete.value = true;
})
.catch((err) => {
console.log(err);
});
};
const forceRemoveFunction = (message: any) => {
const match = message.match(/\[([^\]]+)\]/);
if (match) {
// Convert the matched string to an array of pipeline names
pipelineList.value = JSON.parse(match[0].replace(/'/g, '"'));
}
confirmForceDelete.value = true;
};
const closeDialog = () => {
confirmForceDelete.value = false;
};
const forceDeleteFn = () => {};
const sendToAiChat = (value: any) => {
emit("sendToAiChat", value);
};
const filterData = (rows: any, terms: any) => {
var filtered = [];
terms = terms.toLowerCase();
for (var i = 0; i < rows.length; i++) {
if (rows[i]["name"].toLowerCase().includes(terms)) {
filtered.push(rows[i]);
}
}
return filtered;
};
const visibleRows = computed(() => {
if (!filterQuery.value) return jsTransforms.value || []
return filterData(jsTransforms.value || [], filterQuery.value)
});
const hasVisibleRows = computed(() => visibleRows.value.length > 0);
// Watch visibleRows to sync resultTotal with search filter
watch(visibleRows, (newVisibleRows) => {
resultTotal.value = newVisibleRows.length;
}, { immediate: true });
const openBulkDeleteDialog = () => {
confirmBulkDelete.value = true;
};
const bulkDeleteFunctions = async () => {
const dismiss = toast({
variant: "loading",
message: "Deleting functions...",
timeout: 0,
});
try {
if (selectedFunctions.value.length === 0) {
toast({
variant: "error",
message: "No functions selected for deletion",
});
dismiss();
return;
}
// Extract function names for the API call (BE supports names)
const payload = {
ids: selectedFunctions.value.map((f: any) => f.name),
};
const response = await jsTransformService.bulkDelete(
store.state.selectedOrganization.identifier,
payload
);
dismiss();
// Handle response based on successful/unsuccessful arrays
if (response.data) {
const { successful = [], unsuccessful = [] } = response.data;
const successCount = successful.length;
const failCount = unsuccessful.length;
if (failCount > 0 && successCount > 0) {
// Partial success
toast({
variant: "warning",
message: `${successCount} function(s) deleted successfully, ${failCount} failed`,
timeout: 5000,
});
} else if (failCount > 0) {
// All failed
toast({
variant: "error",
message: `Failed to delete ${failCount} function(s)`,
});
} else {
// All successful
toast({
variant: "success",
message: `${successCount} function(s) deleted successfully`,
});
}
} else {
// Fallback success message
toast({
variant: "success",
message: `${selectedFunctions.value.length} function(s) deleted successfully`,
});
}
selectedFunctions.value = [];
// Refresh functions list
getJSTransforms();
} catch (error: any) {
dismiss();
console.error("Error deleting functions:", error);
// Show error message from response if available
const errorMessage = error.response?.data?.message || error?.message || "Error deleting functions. Please try again.";
if (error.response?.status != 403 || error?.status != 403) {
toast({
variant: "error",
message: errorMessage,
});
}
}
confirmBulkDelete.value = false;
};
// ── Keyboard shortcuts ────────────────────────────────────────────────
useShortcuts([
{
id: "functionsAdd",
handler: () => { if (!isInputFocused()) showAddUpdateFn({}); },
},
{
id: "functionsRefresh",
handler: () => { if (!isInputFocused()) getJSTransforms(); },
},
{
id: "functionsFocusSearch",
handler: () => {
focusSearchInput("functions-list-search-input");
},
},
]);
return {
t,
store,
router,
jsTransforms,
columns,
formData,
hideForm,
confirmDelete,
selectedDelete,
getJSTransforms,
loading,
resultTotal,
refreshList,
pageSize,
pageSizeOptions,
addTransform,
deleteFn,
isUpdated,
showAddUpdateFn,
showDeleteDialogFn,
showAddJSTransformDialog,
forceDeleteFn,
confirmForceDelete,
pipelineList,
selectedPipeline,
closeDialog,
onPipelineSelect,
transformedPipelineList,
getAssociatedPipelines,
filterQuery,
filterData,
getImageURL,
verifyOrganizationStatus,
sendToAiChat,
visibleRows,
hasVisibleRows,
openBulkDeleteDialog,
bulkDeleteFunctions,
confirmBulkDelete,
selectedFunctions,
selectedFunctionIds,
};
},
computed: {
// selectedOrg() {
// return this.store.state.selectedOrganization.identifier;
// },
},
watch: {
// selectedOrg(newVal: any, oldVal: any) {
// this.verifyOrganizationStatus(
// this.store.state.organizations,
// this.router
// );
// if (
// (newVal != oldVal || this.jsTransforms.value == undefined) &&
// this.router.currentRoute.value.name == "AppFunctions"
// ) {
// this.resultTotal = 0;
// this.jsTransforms = [];
// this.getJSTransforms();
// }
// },
},
});
</script>
<style>
.scrollable-list::-webkit-scrollbar {
width: 8px;
}
.scrollable-list::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 4px;
}
.scrollable-list::-webkit-scrollbar-thumb:hover {
background-color: blue;
}
.scrollable-list::-webkit-scrollbar-track {
background-color: blue;
}
</style>