| 1 | <template> |
| 2 | <div class="flex flex-col gap-4"> |
| 3 | <!-- No customer code on alert --> |
| 4 | <n-alert v-if="!alert.customer_code" type="warning" title="No customer associated with this alert"> |
| 5 | Try MCP needs the alert's customer to look up their Shuffle integration. |
| 6 | </n-alert> |
| 7 | |
| 8 | <!-- Loading customer integration --> |
| 9 | <div v-else-if="loadingIntegration" class="text-tertiary py-6 text-center text-sm"> |
| 10 | Loading Shuffle integration for customer {{ alert.customer_code }}… |
| 11 | </div> |
| 12 | |
| 13 | <!-- No integration configured --> |
| 14 | <n-alert v-else-if="!activeIntegration" type="info" title="No Shuffle integration configured"> |
| 15 | This customer has no enabled Shuffle integration. Configure one in |
| 16 | <strong>Customer → AI Notifications → Shuffle integrations</strong> |
| 17 | before using Try MCP. |
| 18 | </n-alert> |
| 19 | |
| 20 | <!-- Loading the org auth token --> |
| 21 | <div v-else-if="loadingToken" class="text-tertiary py-6 text-center text-sm">Fetching org auth token…</div> |
| 22 | |
| 23 | <!-- Token fetch error --> |
| 24 | <n-alert v-else-if="tokenError" type="error" title="Couldn't fetch Shuffle auth token"> |
| 25 | {{ tokenError }} |
| 26 | </n-alert> |
| 27 | |
| 28 | <!-- Active flow: pick an app, then mount TryMcpSection --> |
| 29 | <template v-else-if="orgAuthToken"> |
| 30 | <div class="flex flex-col gap-2"> |
| 31 | <div class="flex items-center justify-between"> |
| 32 | <span class="text-sm"> |
| 33 | Acting as |
| 34 | <strong>{{ activeIntegration.display_name }}</strong> |
| 35 | <span class="text-secondary"> • alert #{{ alert.id }}</span> |
| 36 | </span> |
| 37 | <n-button v-if="selectedAppName" size="tiny" @click="selectedAppName = null"> |
| 38 | <template #icon> |
| 39 | <Icon :name="ResetIcon" :size="14" /> |
| 40 | </template> |
| 41 | Pick a different app |
| 42 | </n-button> |
| 43 | </div> |
| 44 | |
| 45 | <!-- App picker: only mounted until a selection is made --> |
| 46 | <CollapseKeepAlive :show="!selectedAppName"> |
| 47 | <ShuffleMCPEmbed |
| 48 | :auth-token="orgAuthToken" |
| 49 | placeholder="Pick an app to act on this alert (e.g. Crowdstrike, Cloudflare, VirusTotal)…" |
| 50 | inline |
| 51 | class="[&_.singul-container]:max-h-100!" |
| 52 | disable-app-drawer |
| 53 | prevent-default |
| 54 | @app-selected="onAppSelected" |
| 55 | /> |
| 56 | </CollapseKeepAlive> |
| 57 | |
| 58 | <!-- Try MCP for the chosen app --> |
| 59 | <n-collapse-transition :show="!!selectedAppName"> |
| 60 | <TryMcpEmbed v-if="selectedAppName" :app-name="selectedAppName" :auth-token="orgAuthToken" /> |
| 61 | </n-collapse-transition> |
| 62 | </div> |
| 63 | </template> |
| 64 | </div> |
| 65 | </template> |
| 66 | |
| 67 | <script setup lang="ts"> |
| 68 | import type { AppSelectedEvent } from "@shuffleio/shuffle-mcps" |
| 69 | import type { Alert } from "@/types/incidentManagement/alerts.d" |
| 70 | import type { ShuffleIntegration } from "@/types/notifications.d" |
| 71 | import { NAlert, NButton, NCollapseTransition, useMessage } from "naive-ui" |
| 72 | import { computed, onBeforeMount, ref } from "vue" |
| 73 | import Api from "@/api" |
| 74 | import CollapseKeepAlive from "@/components/common/CollapseKeepAlive.vue" |
| 75 | import Icon from "@/components/common/Icon.vue" |
| 76 | import ShuffleMCPEmbed from "@/components/shuffle/ShuffleMCPEmbed.vue" |
| 77 | import TryMcpEmbed from "@/components/shuffle/TryMcpEmbed.vue" |
| 78 | |
| 79 | const props = defineProps<{ alert: Alert }>() |
| 80 | |
| 81 | const ResetIcon = "carbon:reset" |
| 82 | |
| 83 | const message = useMessage() |
| 84 | |
| 85 | const integrations = ref<ShuffleIntegration[]>([]) |
| 86 | const loadingIntegration = ref(false) |
| 87 | const orgAuthToken = ref<string | null>(null) |
| 88 | const loadingToken = ref(false) |
| 89 | const tokenError = ref<string | null>(null) |
| 90 | const selectedAppName = ref<string | null>(null) |
| 91 | |
| 92 | // First enabled integration wins. Multi-integration customers will need |
| 93 | // a picker in a follow-up; vast majority have one. |
| 94 | const activeIntegration = computed<ShuffleIntegration | null>( |
| 95 | () => integrations.value.find(i => i.enabled) ?? integrations.value[0] ?? null |
| 96 | ) |
| 97 | |
| 98 | async function loadIntegrations() { |
| 99 | if (!props.alert.customer_code) return |
| 100 | loadingIntegration.value = true |
| 101 | try { |
| 102 | const res = await Api.notifications.listShuffleIntegrations(props.alert.customer_code) |
| 103 | if (res.data.success) { |
| 104 | integrations.value = res.data?.integrations || [] |
| 105 | if (activeIntegration.value?.shuffle_org_id) { |
| 106 | await loadOrgToken(activeIntegration.value.shuffle_org_id) |
| 107 | } |
| 108 | } else { |
| 109 | message.warning(res.data?.message || "Failed to load Shuffle integrations.") |
| 110 | } |
| 111 | } catch (err: unknown) { |
| 112 | const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message |
| 113 | message.error(msg || "Failed to load Shuffle integrations.") |
| 114 | } finally { |
| 115 | loadingIntegration.value = false |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | async function loadOrgToken(orgId: string) { |
| 120 | loadingToken.value = true |
| 121 | tokenError.value = null |
| 122 | try { |
| 123 | const res = await Api.shuffle.getOrganization(orgId) |
| 124 | if (res.data.success) { |
| 125 | orgAuthToken.value = res.data.data?.org_auth?.token ?? null |
| 126 | if (!orgAuthToken.value) { |
| 127 | tokenError.value = "Shuffle returned an org without an org_auth.token." |
| 128 | } |
| 129 | } else { |
| 130 | tokenError.value = res.data.message || "Failed to fetch org auth token." |
| 131 | } |
| 132 | } catch (err: unknown) { |
| 133 | const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message |
| 134 | tokenError.value = msg || "Failed to fetch org auth token." |
| 135 | } finally { |
| 136 | loadingToken.value = false |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | function onAppSelected(payload: AppSelectedEvent) { |
| 141 | const name = payload.app?.name |
| 142 | if (!name) return |
| 143 | selectedAppName.value = name |
| 144 | } |
| 145 | |
| 146 | onBeforeMount(() => { |
| 147 | loadIntegrations() |
| 148 | }) |
| 149 | </script> |