-
Required
-
Exclusive
+
+ Required
+
+
+ Exclusive
+
mdi-chevron-down
mdi-chevron-up
@@ -92,6 +239,7 @@
class="tag-group-rule-desc"
v-show="
group.isExclusive &&
+ props.validateExclusiveTags &&
selectedItems.some((item) => item.tag_type.id === group.id)
"
>
@@ -109,7 +257,11 @@
v-model="selectedItems"
:id="item.id"
:value="item"
- :disabled="group.isExclusive && isItemDisabled(group, item)"
+ :disabled="
+ group.isExclusive &&
+ props.validateExclusiveTags &&
+ isItemDisabled(group, item)
+ "
class="checkbox-item-box"
/>
{{ item.name }}
@@ -133,37 +285,23 @@
diff --git a/src/dispatch/static/dispatch/src/tag/TagSearchPopover.vue b/src/dispatch/static/dispatch/src/tag/TagSearchPopover.vue
new file mode 100644
index 000000000000..f18afd8582ca
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/tag/TagSearchPopover.vue
@@ -0,0 +1,578 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ tag.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading tags...
+
+
+
+
+
+
+ mdi-alert-circle-outline
+
+
+ {{ fetchError }}
+
+
+
+
+
+
+ No discoverable tags found.
+
+
+
+
+ No tags matching '{{ searchQuery }}' found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tag.name }}
+
+
+
+
+
+
+
+
+
+ mdi-alert-circle-outline
+
+
+
+ Missing required tags from: {{ missingRequiredTagTypes.join(", ") }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/dispatch/static/dispatch/src/tag/api.js b/src/dispatch/static/dispatch/src/tag/api.js
index a4d202a4b8cd..da13b855f753 100644
--- a/src/dispatch/static/dispatch/src/tag/api.js
+++ b/src/dispatch/static/dispatch/src/tag/api.js
@@ -13,8 +13,12 @@ export default {
return API.get(`${resource}/${tagId}`)
},
- getRecommendations(model, modelId) {
- return API.get(`/${resource}/recommendations/${model}/${modelId}`)
+ getRecommendationsCase(projectId, caseId) {
+ return API.get(`/${resource}/recommendations/${projectId}/case/${caseId}`)
+ },
+
+ getRecommendationsIncident(projectId, incidentId) {
+ return API.get(`/${resource}/recommendations/${projectId}/incident/${incidentId}`)
},
create(payload) {
diff --git a/src/dispatch/static/dispatch/src/tag/store.js b/src/dispatch/static/dispatch/src/tag/store.js
index 6118f5a1e85b..0d8b4f9815bf 100644
--- a/src/dispatch/static/dispatch/src/tag/store.js
+++ b/src/dispatch/static/dispatch/src/tag/store.js
@@ -3,6 +3,7 @@ import { debounce } from "lodash"
import SearchUtils from "@/search/utils"
import TagApi from "@/tag/api"
+import TagTypeApi from "@/tag_type/api"
const getDefaultSelectedState = () => {
return {
@@ -42,10 +43,24 @@ const state = {
descending: [false],
filters: {
project: [],
+ tag_type: [],
+ discoverable: [],
},
},
loading: false,
},
+ suggestedTags: [],
+ selectedItems: [],
+ validationError: null,
+ tagTypes: {},
+ groups: [],
+ loading: false,
+ more: false,
+ total: 0,
+ suggestionsLoading: false,
+ suggestionsGenerated: false,
+ suggestionsError: null,
+ tagSuggestions: [],
}
const getters = {
@@ -128,6 +143,336 @@ const actions = {
)
})
},
+ async fetchSuggestedTags({ commit }, suggestedTagData) {
+ // Fetch all tag and tag_type data needed for the suggestions
+ const tagTypeIds = suggestedTagData.map((g) => g.tag_type_id)
+ const tagIds = suggestedTagData.flatMap((g) => g.tags.map((t) => t.id))
+ const tagFilterOptions = {
+ filters: {
+ tagIdFilter: tagIds.map((id) => ({ model: "Tag", field: "id", op: "==", value: id })),
+ tagTypeIdFilter: tagTypeIds.map((id) => ({
+ model: "TagType",
+ field: "id",
+ op: "==",
+ value: id,
+ })),
+ },
+ itemsPerPage: 100,
+ }
+ const params = SearchUtils.createParametersFromTableOptions({ ...tagFilterOptions })
+ const tagResp = await TagApi.getAll(params)
+ const tags = tagResp.data.items
+ // Group tags by tag_type_id
+ const tagTypeMap = {}
+ tags.forEach((tag) => {
+ if (tag.tag_type && tag.tag_type.id) {
+ tagTypeMap[tag.tag_type.id] = tag.tag_type
+ }
+ })
+ // Also ensure all tag_types are present (in case some are not attached to tags)
+ suggestedTagData.forEach((group) => {
+ if (!tagTypeMap[group.tag_type_id]) {
+ tagTypeMap[group.tag_type_id] = {
+ id: group.tag_type_id,
+ name: "",
+ icon: "",
+ color: "#1976d2",
+ }
+ }
+ })
+ // Build the suggestion structure for rendering
+ const result = suggestedTagData.map((group) => {
+ const tag_type = tagTypeMap[group.tag_type_id]
+ return {
+ tag_type,
+ tags: group.tags.map((t) => {
+ const tag = tags.find((tg) => tg.id === t.id)
+ return {
+ ...t,
+ tag,
+ }
+ }),
+ }
+ })
+ commit("SET_SUGGESTED_TAGS", result)
+ },
+ addSuggestedTag({ commit, state }, tagObj) {
+ if (!tagObj || !tagObj.id) return
+ if (!state.selectedItems.some((item) => item.id === tagObj.id)) {
+ commit("ADD_SELECTED_TAG", tagObj)
+ }
+ },
+ removeTag({ commit }, tagId) {
+ commit("REMOVE_SELECTED_TAG", tagId)
+ },
+ validateTags({ commit }, { value, groups, currentProject }) {
+ // project_id logic
+ const project_id = currentProject?.id || 0
+ let all_tags_in_project = false
+ if (project_id) {
+ all_tags_in_project = value.every((tag) => tag.project?.id == project_id)
+ } else {
+ const project_name = currentProject?.name
+ if (!project_name) {
+ commit("SET_VALIDATION_ERROR", true)
+ return
+ }
+ all_tags_in_project = value.every((tag) => tag.project?.name == project_name)
+ }
+ if (all_tags_in_project) {
+ if (!areRequiredTagsSelected(value, groups)) {
+ const required_tag_types = groups
+ .filter((tag_type) => tag_type.isRequired)
+ .map((tag_type) => tag_type.label)
+ commit(
+ "SET_VALIDATION_ERROR",
+ `Please select at least one tag from each required category (${required_tag_types.join(
+ ", "
+ )})`
+ )
+ } else {
+ commit("SET_VALIDATION_ERROR", null)
+ }
+ } else {
+ commit("SET_VALIDATION_ERROR", "Only tags in selected project are allowed")
+ }
+ },
+ async fetchEligibleTagTypes() {
+ // Fetch all tag types where any discoverable_* is true
+ const discoverableFields = [
+ "discoverable_incident",
+ "discoverable_case",
+ "discoverable_signal",
+ "discoverable_query",
+ "discoverable_source",
+ "discoverable_document",
+ ]
+ const orFilters = discoverableFields.map((field) => ({ field, op: "==", value: true }))
+ const params = {
+ filters: { or: orFilters },
+ itemsPerPage: 5000, // adjust as needed
+ }
+ const resp = await TagTypeApi.getAll(params)
+ return resp.data.items.map((tt) => tt.id)
+ },
+
+ async fetchAllTagsWithEligibleTypes({ commit, dispatch }, { project }) {
+ // 1. Fetch eligible tag type ids
+ const eligibleTagTypeIds = await dispatch("fetchEligibleTagTypes")
+ // 2. Fetch tags for this project
+ const tagFilterOptions = {
+ filters: {
+ project: [{ model: "Project", field: "name", op: "==", value: project.name }],
+ tagTypeIdFilter: eligibleTagTypeIds.map((id) => ({
+ model: "TagType",
+ field: "id",
+ op: "==",
+ value: id,
+ })),
+ tagFilter: [{ model: "Tag", field: "discoverable", op: "==", value: "true" }],
+ },
+ itemsPerPage: 5000, // adjust as needed
+ }
+ const params = SearchUtils.createParametersFromTableOptions({ ...tagFilterOptions })
+ const tagResp = await TagApi.getAll(params)
+ // 3. Filter tags client-side as a safeguard
+ const tags = tagResp.data.items.filter((tag) => eligibleTagTypeIds.includes(tag.tag_type.id))
+
+ commit("SET_TABLE_ROWS", { items: tags, total: tags.length })
+ return tags
+ },
+
+ async fetchTags({ commit, dispatch }, { project, model }) {
+ // Handle both single project object and array of projects
+ const projects = Array.isArray(project) ? project : [project]
+ const validProjects = projects.filter((p) => p && p.name)
+
+ if (!validProjects.length) {
+ commit("SET_TABLE_ROWS", { items: [], total: 0 })
+ commit("SET_GROUPS", {})
+ return []
+ }
+
+ commit("SET_LOADING", true)
+
+ try {
+ let relevantTagTypeIds = []
+
+ // If model is specified, first fetch the relevant TagType IDs
+ if (model) {
+ const tagTypeFilterOptions = {
+ filter: JSON.stringify([
+ {
+ and: [{ model: "TagType", field: "discoverable_" + model, op: "==", value: "true" }],
+ },
+ ]),
+ itemsPerPage: -1,
+ fields: JSON.stringify(["id"]),
+ }
+
+ const tagTypeResponse = await TagTypeApi.getAll(tagTypeFilterOptions)
+ relevantTagTypeIds = tagTypeResponse.data.items.map((tt) => tt.id)
+
+ if (!relevantTagTypeIds.length) {
+ commit("SET_TABLE_ROWS", { items: [], total: 0 })
+ commit("SET_GROUPS", {})
+ commit("SET_LOADING", false)
+ return []
+ }
+ }
+
+ // Fetch tags for each project and combine them
+ const allTags = []
+ for (const singleProject of validProjects) {
+ const projectTags = await dispatch("fetchTagsForSingleProject", {
+ project: singleProject,
+ relevantTagTypeIds,
+ model,
+ })
+ if (projectTags) {
+ allTags.push(...projectTags)
+ }
+ }
+ // Update the store with combined results
+ commit("SET_TABLE_ROWS", { items: allTags, total: allTags.length })
+ commit("SET_GROUPS", convertData(allTags))
+ commit("SET_LOADING", false)
+ return allTags
+ } catch (error) {
+ console.error("Error fetching tags:", error)
+ commit("SET_LOADING", false)
+ throw error
+ }
+ },
+
+ async fetchTagsForSingleProject(_, { project, relevantTagTypeIds, model }) {
+ // Build the tag filter options using the same direct approach as tagTypeFilterOptions
+ let baseFilters = [{ model: "Tag", field: "discoverable", op: "==", value: "true" }]
+
+ // Add project filter if project is defined
+ if (project && project.name) {
+ baseFilters.unshift({ model: "Project", field: "name", op: "==", value: project.name })
+ }
+
+ let tagFilterOptions = {
+ filter: JSON.stringify([
+ {
+ and: baseFilters,
+ },
+ ]),
+ itemsPerPage: 500,
+ sortBy: ["tag_type.name"],
+ sortDesc: [false],
+ }
+
+ // If we have relevant tag type IDs, add the filter
+ if (model && relevantTagTypeIds.length > 0) {
+ baseFilters.push({
+ model: "Tag",
+ field: "tag_type_id",
+ op: "in",
+ value: relevantTagTypeIds,
+ })
+ tagFilterOptions.filter = JSON.stringify([
+ {
+ and: baseFilters,
+ },
+ ])
+ }
+
+ const response = await TagApi.getAll(tagFilterOptions)
+ return response.data.items
+ },
+
+ async fetchTagTypes({ commit }) {
+ try {
+ const resp = await TagTypeApi.getAll({ itemsPerPage: 5000 })
+ const tagTypes = Object.fromEntries(resp.data.items.map((tt) => [tt.id, tt]))
+
+ // Add sample tag types for demo purposes if they don't exist
+ if (!tagTypes[135]) {
+ tagTypes[135] = {
+ id: 135,
+ name: "MITRE Tactics",
+ color: "#1976d2",
+ icon: "bullseye-arrow",
+ }
+ }
+ if (!tagTypes[136]) {
+ tagTypes[136] = {
+ id: 136,
+ name: "MITRE Techniques",
+ color: "#388e3c",
+ icon: "tools",
+ }
+ }
+
+ commit("SET_TAG_TYPES", tagTypes)
+ return tagTypes
+ } catch (error) {
+ console.error("Error fetching tag types:", error)
+ throw error
+ }
+ },
+
+ async generateSuggestions({ commit }, { projectId, modelId, modelType = "incident" }) {
+ commit("SET_SUGGESTIONS_LOADING", true)
+ commit("SET_SUGGESTIONS_ERROR", null)
+
+ try {
+ let response
+ if (modelType === "case") {
+ response = await TagApi.getRecommendationsCase(projectId, modelId)
+ } else {
+ response = await TagApi.getRecommendationsIncident(projectId, modelId)
+ }
+
+ const errorMessage = response.data?.error_message || response.error_message
+ if (errorMessage) {
+ commit("SET_SUGGESTIONS_ERROR", errorMessage)
+ commit("SET_TAG_SUGGESTIONS", [])
+ return
+ }
+
+ const suggestions = response.data?.recommendations || response.recommendations || []
+ commit("SET_TAG_SUGGESTIONS", Array.isArray(suggestions) ? suggestions : [])
+ commit("SET_SUGGESTIONS_GENERATED", true)
+ } catch (error) {
+ console.error("Error generating AI suggestions:", error)
+ commit(
+ "SET_SUGGESTIONS_ERROR",
+ "Failed to generate AI tag suggestions. Please try again later."
+ )
+ commit("SET_TAG_SUGGESTIONS", [])
+ } finally {
+ commit("SET_SUGGESTIONS_LOADING", false)
+ commit("SET_SUGGESTIONS_GENERATED", true)
+ }
+ },
+
+ resetSuggestions({ commit }) {
+ commit("SET_SUGGESTIONS_GENERATED", false)
+ commit("SET_SUGGESTIONS_ERROR", null)
+ },
+
+ getTagType({ state }, tagTypeId) {
+ return state.tagTypes[tagTypeId] || {}
+ },
+
+ convertDataAndSetGroups({ commit }, data) {
+ commit("SET_GROUPS", convertData(data))
+ },
+}
+
+function areRequiredTagsSelected(sel, tagTypes) {
+ for (let i = 0; i < tagTypes.length; i++) {
+ if (tagTypes[i].isRequired) {
+ if (!sel.some((item) => item.tag_type?.id === tagTypes[i]?.id)) {
+ return false
+ }
+ }
+ }
+ return true
}
const mutations = {
@@ -156,6 +501,65 @@ const mutations = {
state.selected = { ...getDefaultSelectedState() }
state.selected.project = project
},
+ SET_SUGGESTED_TAGS(state, tags) {
+ state.suggestedTags = tags
+ },
+ ADD_SELECTED_TAG(state, tag) {
+ state.selectedItems = [...(state.selectedItems || []), tag]
+ },
+ REMOVE_SELECTED_TAG(state, tagId) {
+ state.selectedItems = (state.selectedItems || []).filter((item) => item.id !== tagId)
+ },
+ SET_VALIDATION_ERROR(state, error) {
+ state.validationError = error
+ },
+ SET_LOADING(state, value) {
+ state.loading = value
+ },
+ SET_MORE(state, value) {
+ state.more = value
+ },
+ SET_TOTAL(state, value) {
+ state.total = value
+ },
+ SET_GROUPS(state, groups) {
+ state.groups = groups
+ },
+ SET_TAG_TYPES(state, types) {
+ state.tagTypes = types
+ },
+ SET_SUGGESTIONS_LOADING(state, value) {
+ state.suggestionsLoading = value
+ },
+ SET_SUGGESTIONS_GENERATED(state, value) {
+ state.suggestionsGenerated = value
+ },
+ SET_SUGGESTIONS_ERROR(state, error) {
+ state.suggestionsError = error
+ },
+ SET_TAG_SUGGESTIONS(state, suggestions) {
+ state.tagSuggestions = suggestions
+ },
+}
+
+// Helper function for converting data
+function convertData(data) {
+ return data.reduce((r, a) => {
+ if (!r[a.tag_type.id]) {
+ r[a.tag_type.id] = {
+ id: a.tag_type.id,
+ icon: a.tag_type.icon,
+ label: a.tag_type.name,
+ desc: a.tag_type.description,
+ color: a.tag_type.color,
+ isRequired: a.tag_type.required,
+ isExclusive: a.tag_type.exclusive,
+ menuItems: [],
+ }
+ }
+ r[a.tag_type.id].menuItems.push(a)
+ return r
+ }, Object.create(null))
}
export default {
diff --git a/src/dispatch/static/dispatch/src/tag_type/NewEditSheet.vue b/src/dispatch/static/dispatch/src/tag_type/NewEditSheet.vue
index dd0a659e6d2a..4fa297a5b60e 100644
--- a/src/dispatch/static/dispatch/src/tag_type/NewEditSheet.vue
+++ b/src/dispatch/static/dispatch/src/tag_type/NewEditSheet.vue
@@ -170,6 +170,23 @@
+
+
+
+
+
+
+ mdi-information
+
+
+ If activated, GenAI will provide tag suggestions for this tag type in the UI.
+
+
+
@@ -217,6 +234,7 @@ export default {
"selected.exclusive",
"selected.required",
"selected.use_for_project_folder",
+ "selected.genai_suggestions",
"selected.loading",
]),
...mapFields("tag_type", {
diff --git a/src/dispatch/static/dispatch/src/tag_type/Table.vue b/src/dispatch/static/dispatch/src/tag_type/Table.vue
index 355398ba680e..a416f1ec01f3 100644
--- a/src/dispatch/static/dispatch/src/tag_type/Table.vue
+++ b/src/dispatch/static/dispatch/src/tag_type/Table.vue
@@ -33,28 +33,31 @@
:loading="loading"
loading-text="Loading... Please wait"
>
-
+
-
-
+
+
mdi-dots-vertical
-
+
View / Edit
-
- {{ combine(item) }}
+
+ {{ combine(slotProps.item) }}
-
-
+
+
-
-
+
+
+
+
+
@@ -94,6 +97,7 @@ export default {
{ title: "Discoverability", value: "discoverability", sortable: false },
{ title: "Required", value: "required", sortable: false },
{ title: "Exclusive", value: "exclusive", sortable: false },
+ { title: "GenAI Suggestions", value: "genai_suggestions", sortable: false },
{ title: "", key: "data-table-actions", sortable: false, align: "end" },
],
}
diff --git a/src/dispatch/static/dispatch/src/task/TableExportDialog.vue b/src/dispatch/static/dispatch/src/task/TableExportDialog.vue
index a44c07591d0e..65645a794879 100644
--- a/src/dispatch/static/dispatch/src/task/TableExportDialog.vue
+++ b/src/dispatch/static/dispatch/src/task/TableExportDialog.vue
@@ -78,7 +78,12 @@