| 1 | <template> |
| 2 | <div :id="`customer-${customer.customer_code}`"> |
| 3 | <CardEntity :loading :highlighted="!!highlight" :hide-footer-extra="!!hideCardActions"> |
| 4 | <template #default> |
| 5 | <div class="flex items-start gap-4"> |
| 6 | <n-avatar |
| 7 | :src="customerInfo?.logo_file || fallbackAvatar" |
| 8 | :fallback-src="fallbackAvatar" |
| 9 | round |
| 10 | :size="40" |
| 11 | /> |
| 12 | |
| 13 | <div class="flex flex-col gap-1"> |
| 14 | <div class="flex flex-wrap gap-2"> |
| 15 | {{ customerInfo?.customer_name }} |
| 16 | <span class="text-secondary">#{{ customer.customer_code }}</span> |
| 17 | </div> |
| 18 | <p class="text-sm"> |
| 19 | {{ customerInfo?.contact_first_name }} {{ customerInfo?.contact_last_name }} |
| 20 | </p> |
| 21 | </div> |
| 22 | </div> |
| 23 | </template> |
| 24 | |
| 25 | <template #footerMain> |
| 26 | <div class="flex flex-wrap items-center gap-3"> |
| 27 | <Badge type="splitted" color="primary"> |
| 28 | <template #iconLeft> |
| 29 | <Icon :name="UserTypeIcon" :size="14" /> |
| 30 | </template> |
| 31 | <template #label>Type</template> |
| 32 | <template #value> |
| 33 | {{ customerInfo?.customer_type || "-" }} |
| 34 | </template> |
| 35 | </Badge> |
| 36 | |
| 37 | <n-popover trigger="hover" :disabled="addressLabel === '-'"> |
| 38 | <template #trigger> |
| 39 | <Badge type="splitted" color="primary" :class="{ 'cursor-help': addressLabel !== '-' }"> |
| 40 | <template #iconLeft> |
| 41 | <Icon :name="LocationIcon" :size="13" /> |
| 42 | </template> |
| 43 | <template #value> |
| 44 | <div class="flex flex-wrap items-center gap-2"> |
| 45 | {{ addressLabel }} |
| 46 | <Icon |
| 47 | v-if="addressLabel !== '-'" |
| 48 | :name="InfoIcon" |
| 49 | :size="13" |
| 50 | class="opacity-80!" |
| 51 | /> |
| 52 | </div> |
| 53 | </template> |
| 54 | </Badge> |
| 55 | </template> |
| 56 | |
| 57 | <div class="flex flex-col gap-1"> |
| 58 | <div v-if="customerInfo?.address_line1" class="box"> |
| 59 | address_line1: |
| 60 | <code>{{ customerInfo.address_line1 }}</code> |
| 61 | </div> |
| 62 | <div v-if="customerInfo?.address_line2" class="box"> |
| 63 | address_line2: |
| 64 | <code>{{ customerInfo.address_line2 }}</code> |
| 65 | </div> |
| 66 | <div v-if="customerInfo?.postal_code" class="box"> |
| 67 | postal_code: |
| 68 | <code>{{ customerInfo.postal_code }}</code> |
| 69 | </div> |
| 70 | <div v-if="customerInfo?.city" class="box"> |
| 71 | city: |
| 72 | <code>{{ customerInfo.city }}</code> |
| 73 | </div> |
| 74 | <div v-if="customerInfo?.state" class="box"> |
| 75 | state: |
| 76 | <code>{{ customerInfo.state }}</code> |
| 77 | </div> |
| 78 | <div v-if="customerInfo?.country" class="box"> |
| 79 | country: |
| 80 | <code>{{ customerInfo.country }}</code> |
| 81 | </div> |
| 82 | </div> |
| 83 | </n-popover> |
| 84 | |
| 85 | <Badge type="splitted" color="primary"> |
| 86 | <template #iconLeft> |
| 87 | <Icon :name="PhoneIcon" :size="13" /> |
| 88 | </template> |
| 89 | <template #value> |
| 90 | {{ customerInfo?.phone || "-" }} |
| 91 | </template> |
| 92 | </Badge> |
| 93 | |
| 94 | <Badge v-if="customerInfo?.parent_customer_code" type="splitted" color="primary"> |
| 95 | <template #iconLeft> |
| 96 | <Icon :name="ParentIcon" :size="13" /> |
| 97 | </template> |
| 98 | <template #label>Parent</template> |
| 99 | <template #value> |
| 100 | {{ customerInfo?.parent_customer_code }} |
| 101 | </template> |
| 102 | </Badge> |
| 103 | |
| 104 | <Badge type="splitted" :color="customer.is_provisioned ? 'success' : 'danger'" bright> |
| 105 | <template #iconLeft> |
| 106 | <Icon :name="ProvisionIcon" :size="13" /> |
| 107 | </template> |
| 108 | <template #label>Status</template> |
| 109 | <template #value> |
| 110 | {{ customer.is_provisioned ? "Provisioned" : "Not Provisioned" }} |
| 111 | </template> |
| 112 | </Badge> |
| 113 | |
| 114 | <Badge v-if="customer.is_provisioned && agentCount !== null" type="splitted" color="primary"> |
| 115 | <template #iconLeft> |
| 116 | <Icon :name="AgentsIcon" :size="13" /> |
| 117 | </template> |
| 118 | <template #label>Agents</template> |
| 119 | <template #value> |
| 120 | {{ agentCount }} |
| 121 | </template> |
| 122 | </Badge> |
| 123 | </div> |
| 124 | </template> |
| 125 | |
| 126 | <template #footerExtra> |
| 127 | <n-button size="small" @click.stop="showDetails = true"> |
| 128 | <template #icon> |
| 129 | <Icon :name="DetailsIcon" /> |
| 130 | </template> |
| 131 | Details |
| 132 | </n-button> |
| 133 | </template> |
| 134 | </CardEntity> |
| 135 | |
| 136 | <n-modal |
| 137 | v-model:show="showDetails" |
| 138 | preset="card" |
| 139 | content-class="p-0!" |
| 140 | :style="{ maxWidth: 'min(1100px, 90vw)', minHeight: 'min(470px, 90vh)', overflow: 'hidden' }" |
| 141 | :title="customerInfo?.customer_name" |
| 142 | :bordered="false" |
| 143 | segmented |
| 144 | > |
| 145 | <n-tabs type="line" animated :tabs-padding="24" class="h-full"> |
| 146 | <n-tab-pane name="Customer" tab="Customer" display-directive="show" class="pt-0!"> |
| 147 | <n-tabs |
| 148 | type="line" |
| 149 | animated |
| 150 | :tabs-padding="24" |
| 151 | class="[&_.n-tabs-nav]:bg-secondary z-20 h-full [&_.n-tabs-nav]:relative [&_.n-tabs-nav]:z-99" |
| 152 | placement="left" |
| 153 | :pane-style="{ minHeight: 'min(450px, 90vh)' }" |
| 154 | > |
| 155 | <n-tab-pane name="Info" tab="Info" display-directive="show:lazy" class="p-4!"> |
| 156 | <CustomerInfo |
| 157 | v-if="customerInfo" |
| 158 | v-model:loading="loadingDelete" |
| 159 | :customer="customerInfo" |
| 160 | @delete="deletedItem()" |
| 161 | @submitted="customerInfo = $event" |
| 162 | /> |
| 163 | </n-tab-pane> |
| 164 | <n-tab-pane name="Provision" tab="Provision" display-directive="show:lazy" class="p-4!"> |
| 165 | <CustomerProvision |
| 166 | :customer-meta |
| 167 | :customer-code="customer.customer_code" |
| 168 | :customer-name="customer.customer_name" |
| 169 | @delete="customerMeta = null" |
| 170 | @submitted="customerMeta = $event" |
| 171 | /> |
| 172 | </n-tab-pane> |
| 173 | <n-tab-pane |
| 174 | name="3rd Party Integrations" |
| 175 | tab="3rd Party Integrations" |
| 176 | display-directive="show:lazy" |
| 177 | class="p-4!" |
| 178 | > |
| 179 | <CustomerIntegrations |
| 180 | :customer-code="customer.customer_code" |
| 181 | :customer-name="customer.customer_name" |
| 182 | /> |
| 183 | </n-tab-pane> |
| 184 | <n-tab-pane |
| 185 | name="Network Connectors" |
| 186 | tab="Network Connectors" |
| 187 | display-directive="show:lazy" |
| 188 | class="p-4!" |
| 189 | > |
| 190 | <CustomerNetworkConnectors |
| 191 | :customer-code="customer.customer_code" |
| 192 | :customer-name="customer.customer_name" |
| 193 | /> |
| 194 | </n-tab-pane> |
| 195 | <n-tab-pane |
| 196 | name="Notification Workflows" |
| 197 | tab="Notification Workflows" |
| 198 | display-directive="show:lazy" |
| 199 | class="p-4!" |
| 200 | > |
| 201 | <CustomerNotificationsWorkflows :customer-code="customer.customer_code" /> |
| 202 | </n-tab-pane> |
| 203 | <n-tab-pane name="AI Triggers" tab="AI Triggers" display-directive="show:lazy" class="p-4!"> |
| 204 | <CustomerAITriggers :customer-code="customer.customer_code" /> |
| 205 | </n-tab-pane> |
| 206 | <n-tab-pane |
| 207 | name="AI Notifications" |
| 208 | tab="AI Notifications" |
| 209 | display-directive="show:lazy" |
| 210 | class="overflow-hidden p-4!" |
| 211 | > |
| 212 | <CustomerAiNotifications :customer-code="customer.customer_code" /> |
| 213 | </n-tab-pane> |
| 214 | <n-tab-pane name="Event Sources" tab="Event Sources" display-directive="show:lazy" class="p-4!"> |
| 215 | <CustomerEventSources :customer-code="customer.customer_code" /> |
| 216 | </n-tab-pane> |
| 217 | |
| 218 | <template #suffix><div class="h-4 w-full"></div></template> |
| 219 | </n-tabs> |
| 220 | </n-tab-pane> |
| 221 | |
| 222 | <n-tab-pane name="Agents" tab="Agents" display-directive="show" class="pt-0!"> |
| 223 | <n-tabs |
| 224 | type="line" |
| 225 | animated |
| 226 | :tabs-padding="24" |
| 227 | class="[&_.n-tabs-nav]:bg-secondary z-20 h-full [&_.n-tabs-nav]:relative [&_.n-tabs-nav]:z-99" |
| 228 | placement="left" |
| 229 | :pane-style="{ minHeight: 'min(450px, 90vh)' }" |
| 230 | > |
| 231 | <n-tab-pane name="Agents" tab="Agents" display-directive="show" class="p-4! pr-0!"> |
| 232 | <n-scrollbar style="max-height: 470px" trigger="none" class="pr-4"> |
| 233 | <CustomerAgents v-if="customerInfo" :customer="customerInfo" /> |
| 234 | </n-scrollbar> |
| 235 | </n-tab-pane> |
| 236 | <n-tab-pane |
| 237 | name="Healthcheck Wazuh" |
| 238 | tab="Healthcheck Wazuh" |
| 239 | display-directive="show:lazy" |
| 240 | class="p-4! pr-0!" |
| 241 | > |
| 242 | <n-scrollbar style="max-height: 470px" trigger="none" class="pr-4"> |
| 243 | <CustomerHealthcheckList |
| 244 | v-model:filters="healthcheckFilters" |
| 245 | source="wazuh" |
| 246 | :customer-code="customer.customer_code" |
| 247 | /> |
| 248 | </n-scrollbar> |
| 249 | </n-tab-pane> |
| 250 | <n-tab-pane |
| 251 | name="Healthcheck Velociraptor" |
| 252 | tab="Healthcheck Velociraptor" |
| 253 | display-directive="show:lazy" |
| 254 | class="p-4! pr-0!" |
| 255 | > |
| 256 | <n-scrollbar style="max-height: 470px" trigger="none" class="pr-4"> |
| 257 | <CustomerHealthcheckList |
| 258 | v-model:filters="healthcheckFilters" |
| 259 | source="velociraptor" |
| 260 | :customer-code="customer.customer_code" |
| 261 | /> |
| 262 | </n-scrollbar> |
| 263 | </n-tab-pane> |
| 264 | |
| 265 | <template #suffix><div class="h-4 w-full"></div></template> |
| 266 | </n-tabs> |
| 267 | </n-tab-pane> |
| 268 | |
| 269 | <n-tab-pane |
| 270 | v-if="customerPortainerStackId !== null" |
| 271 | name="Wazuh Worker" |
| 272 | tab="Wazuh Worker" |
| 273 | display-directive="show" |
| 274 | class="p-4!" |
| 275 | > |
| 276 | <CustomerWazuhWorker :stack-id="customerPortainerStackId" /> |
| 277 | </n-tab-pane> |
| 278 | </n-tabs> |
| 279 | </n-modal> |
| 280 | </div> |
| 281 | </template> |
| 282 | |
| 283 | <script setup lang="ts"> |
| 284 | // TODO-FE: refactor |
| 285 | import type { Customer, CustomerMeta } from "@/types/customers.d" |
| 286 | import { NAvatar, NButton, NModal, NPopover, NScrollbar, NTabPane, NTabs, useMessage } from "naive-ui" |
| 287 | import { computed, defineAsyncComponent, onBeforeMount, ref, toRefs, watch } from "vue" |
| 288 | import Api from "@/api" |
| 289 | import Badge from "@/components/common/Badge.vue" |
| 290 | import CardEntity from "@/components/common/cards/CardEntity.vue" |
| 291 | import Icon from "@/components/common/Icon.vue" |
| 292 | import { useCustomerHealthcheckFilters } from "@/composables/useCustomerHealthcheckFilters" |
| 293 | import { getAvatar, getNameInitials } from "@/utils" |
| 294 | |
| 295 | const props = defineProps<{ |
| 296 | customer: Customer |
| 297 | highlight?: boolean | null | undefined |
| 298 | hideCardActions?: boolean | null | undefined |
| 299 | }>() |
| 300 | const emit = defineEmits<{ |
| 301 | (e: "delete"): void |
| 302 | }>() |
| 303 | const CustomerInfo = defineAsyncComponent(() => import("./CustomerInfo.vue")) |
| 304 | const CustomerAgents = defineAsyncComponent(() => import("./CustomerAgents.vue")) |
| 305 | const CustomerProvision = defineAsyncComponent(() => import("./provision/CustomerProvision.vue")) |
| 306 | const CustomerHealthcheckList = defineAsyncComponent(() => import("./healthcheck/CustomerHealthcheckList.vue")) |
| 307 | const CustomerIntegrations = defineAsyncComponent(() => import("./integrations/CustomerIntegrations.vue")) |
| 308 | const CustomerNetworkConnectors = defineAsyncComponent( |
| 309 | () => import("./networkConnectors/CustomerNetworkConnectors.vue") |
| 310 | ) |
| 311 | const CustomerNotificationsWorkflows = defineAsyncComponent( |
| 312 | () => import("./notifications/CustomerNotificationsWorkflows.vue") |
| 313 | ) |
| 314 | const CustomerAITriggers = defineAsyncComponent(() => import("./aiTriggers/CustomerAITriggers.vue")) |
| 315 | const CustomerAiNotifications = defineAsyncComponent(() => import("./aiNotifications/CustomerAiNotifications.vue")) |
| 316 | const CustomerEventSources = defineAsyncComponent(() => import("./eventSources/CustomerEventSources.vue")) |
| 317 | const CustomerWazuhWorker = defineAsyncComponent(() => import("./CustomerWazuhWorker.vue")) |
| 318 | |
| 319 | const { customer, highlight, hideCardActions } = toRefs(props) |
| 320 | |
| 321 | const DetailsIcon = "carbon:settings-adjust" |
| 322 | const UserTypeIcon = "solar:shield-user-linear" |
| 323 | const ParentIcon = "material-symbols-light:supervisor-account-outline-rounded" |
| 324 | const InfoIcon = "carbon:information" |
| 325 | const LocationIcon = "carbon:location" |
| 326 | const PhoneIcon = "carbon:phone" |
| 327 | const ProvisionIcon = "carbon:network-3" |
| 328 | const AgentsIcon = "carbon:devices" |
| 329 | |
| 330 | const showDetails = ref(false) |
| 331 | const loadingFull = ref(false) |
| 332 | const loadingDelete = ref(false) |
| 333 | const message = useMessage() |
| 334 | const customerInfo = ref<Customer | null>(null) |
| 335 | const customerMeta = ref<CustomerMeta | null>(null) |
| 336 | const customerPortainerStackId = ref<number | null>(null) |
| 337 | const agentCount = ref<number | null>(null) |
| 338 | const { healthcheckFilters } = useCustomerHealthcheckFilters() |
| 339 | |
| 340 | const loading = computed(() => loadingFull.value || loadingDelete.value) |
| 341 | |
| 342 | const fallbackAvatar = computed(() => { |
| 343 | const initials = getNameInitials(customer.value.customer_name) |
| 344 | return getAvatar({ seed: initials, text: initials }) |
| 345 | }) |
| 346 | |
| 347 | const addressLabel = computed( |
| 348 | () => [customerInfo.value?.city, customerInfo.value?.state].filter(o => !!o).join(", ") || "-" |
| 349 | ) |
| 350 | |
| 351 | function getFull() { |
| 352 | loadingFull.value = true |
| 353 | |
| 354 | Api.customers |
| 355 | .getCustomerFull(customer.value.customer_code) |
| 356 | .then(res => { |
| 357 | if (res.data.success) { |
| 358 | customerInfo.value = res.data.customer |
| 359 | customerMeta.value = res.data.customer_meta || null |
| 360 | } else { |
| 361 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 362 | } |
| 363 | }) |
| 364 | .catch(err => { |
| 365 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 366 | }) |
| 367 | .finally(() => { |
| 368 | loadingFull.value = false |
| 369 | }) |
| 370 | } |
| 371 | |
| 372 | function getPortainerStackId() { |
| 373 | Api.portainer.getCustomerStackId(customer.value.customer_name).then(res => { |
| 374 | if (res.data.success) { |
| 375 | customerPortainerStackId.value = res.data.stack_id || null |
| 376 | } else { |
| 377 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 378 | } |
| 379 | }) |
| 380 | } |
| 381 | |
| 382 | function getAgentCount() { |
| 383 | Api.customers |
| 384 | .getCustomerAgents(customer.value.customer_code) |
| 385 | .then(res => { |
| 386 | if (res.data.success) { |
| 387 | agentCount.value = res.data.agents?.length || 0 |
| 388 | } |
| 389 | }) |
| 390 | .catch(err => { |
| 391 | console.error("Failed to fetch agent count:", err) |
| 392 | }) |
| 393 | } |
| 394 | |
| 395 | function deletedItem() { |
| 396 | showDetails.value = false |
| 397 | emit("delete") |
| 398 | } |
| 399 | |
| 400 | watch(showDetails, val => { |
| 401 | if (val) { |
| 402 | if ( |
| 403 | customer.value.customer_code && |
| 404 | (!customer.value.customer_name || !customerMeta.value?.customer_meta_graylog_index) |
| 405 | ) { |
| 406 | getFull() |
| 407 | } |
| 408 | |
| 409 | if (!customerPortainerStackId.value) { |
| 410 | getPortainerStackId() |
| 411 | } |
| 412 | } |
| 413 | }) |
| 414 | |
| 415 | onBeforeMount(() => { |
| 416 | customerInfo.value = customer.value |
| 417 | |
| 418 | if (customer.value.customer_code && !customer.value.customer_name) { |
| 419 | getFull() |
| 420 | } |
| 421 | |
| 422 | if (customer.value.is_provisioned) { |
| 423 | getAgentCount() |
| 424 | } |
| 425 | }) |
| 426 | </script> |