mirror of
https://github.com/hanzoai/insights.git
synced 2026-11-02 14:32:45 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
515 lines
20 KiB
TypeScript
515 lines
20 KiB
TypeScript
import { MakeLogicType, actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
|
|
import { loaders } from 'kea-loaders'
|
|
import insights from 'insights-js'
|
|
|
|
import api from 'lib/api'
|
|
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
|
|
import { toast } from 'lib/elements/Toast/Toast'
|
|
import { deleteWithUndo } from 'lib/utils/deleteWithUndo'
|
|
import { projectLogic } from 'scenes/projectLogic'
|
|
|
|
import { InsightsFunctionType, IntegrationType } from '~/types'
|
|
|
|
import { alertsTestDeliveryCreate } from 'products/alerts/frontend/generated/api'
|
|
import type { AlertTestDeliveryResponseApi } from 'products/alerts/frontend/generated/api.schemas'
|
|
import {
|
|
ALERT_NOTIFICATION_TYPE_DISCORD,
|
|
ALERT_NOTIFICATION_TYPE_MICROSOFT_TEAMS,
|
|
ALERT_NOTIFICATION_TYPE_SLACK,
|
|
ALERT_NOTIFICATION_TYPE_WEBHOOK,
|
|
AlertNotificationType,
|
|
PendingAlertNotification,
|
|
buildAlertFilterConfig,
|
|
buildInsightsFunctionPayload,
|
|
notificationTypeFromTemplateId,
|
|
} from 'products/alerts/frontend/logic/alertNotifications'
|
|
|
|
import type { UserBasicType } from '../../../../frontend/src/types'
|
|
|
|
export const ALERT_NOTIFICATION_TYPE_OPTIONS = [
|
|
{ label: 'Slack', value: ALERT_NOTIFICATION_TYPE_SLACK },
|
|
{ label: 'Discord', value: ALERT_NOTIFICATION_TYPE_DISCORD },
|
|
{ label: 'Microsoft Teams', value: ALERT_NOTIFICATION_TYPE_MICROSOFT_TEAMS },
|
|
{ label: 'Webhook', value: ALERT_NOTIFICATION_TYPE_WEBHOOK },
|
|
]
|
|
|
|
export interface AlertNotificationLogicProps {
|
|
alertId?: string
|
|
loadDestinations?: boolean
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface alertNotificationLogicValues {
|
|
integrationsLoading: boolean // integrationsLogic
|
|
slackIntegrations: IntegrationType[] | undefined // integrationsLogic
|
|
currentProjectId: number | null // projectLogic
|
|
existingInsightsFunctions: InsightsFunctionType[]
|
|
existingInsightsFunctionsLoading: boolean
|
|
integrationsFailed: boolean
|
|
pendingNotifications: PendingAlertNotification[]
|
|
selectedSlackIntegration: IntegrationType | undefined
|
|
selectedSlackIntegrationId: number | null
|
|
selectedType: AlertNotificationType
|
|
slackChannelValue: string | null
|
|
testDeliveryResult: AlertTestDeliveryResponseApi | null
|
|
testDeliveryResultLoading: boolean
|
|
webhookUrl: string
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface alertNotificationLogicActions {
|
|
loadIntegrations: () => any // integrationsLogic
|
|
loadIntegrationsFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
} // integrationsLogic
|
|
loadIntegrationsSuccess: (
|
|
integrations: {
|
|
config: any
|
|
created_at: string
|
|
created_by?: UserBasicType | null | undefined
|
|
display_name: string
|
|
errors?: string | undefined
|
|
icon_url: any
|
|
id: number
|
|
kind:
|
|
| 'apns'
|
|
| 'aws-s3'
|
|
| 'azure-blob'
|
|
| 'bing-ads'
|
|
| 'clickup'
|
|
| 'customerio-app'
|
|
| 'customerio-track'
|
|
| 'customerio-webhook'
|
|
| 'databricks'
|
|
| 'email'
|
|
| 'firebase'
|
|
| 'github'
|
|
| 'gitlab'
|
|
| 'google-ads'
|
|
| 'google-analytics'
|
|
| 'google-cloud-service-account'
|
|
| 'google-cloud-storage'
|
|
| 'google-pubsub'
|
|
| 'google-search-console'
|
|
| 'google-sheets'
|
|
| 'hubspot'
|
|
| 'intercom'
|
|
| 'jira'
|
|
| 'linear'
|
|
| 'linkedin-ads'
|
|
| 'meta-ads'
|
|
| 'pardot'
|
|
| 'pinterest-ads'
|
|
| 'postgresql'
|
|
| 'reddit-ads'
|
|
| 's3-compatible'
|
|
| 'salesforce'
|
|
| 'slack'
|
|
| 'snapchat'
|
|
| 'snowflake'
|
|
| 'stripe'
|
|
| 'tiktok-ads'
|
|
| 'twilio'
|
|
| 'vercel'
|
|
}[],
|
|
payload?: any
|
|
) => {
|
|
integrations: {
|
|
config: any
|
|
created_at: string
|
|
created_by?: UserBasicType | null | undefined
|
|
display_name: string
|
|
errors?: string | undefined
|
|
icon_url: any
|
|
id: number
|
|
kind:
|
|
| 'apns'
|
|
| 'aws-s3'
|
|
| 'azure-blob'
|
|
| 'bing-ads'
|
|
| 'clickup'
|
|
| 'customerio-app'
|
|
| 'customerio-track'
|
|
| 'customerio-webhook'
|
|
| 'databricks'
|
|
| 'email'
|
|
| 'firebase'
|
|
| 'github'
|
|
| 'gitlab'
|
|
| 'google-ads'
|
|
| 'google-analytics'
|
|
| 'google-cloud-service-account'
|
|
| 'google-cloud-storage'
|
|
| 'google-pubsub'
|
|
| 'google-search-console'
|
|
| 'google-sheets'
|
|
| 'hubspot'
|
|
| 'intercom'
|
|
| 'jira'
|
|
| 'linear'
|
|
| 'linkedin-ads'
|
|
| 'meta-ads'
|
|
| 'pardot'
|
|
| 'pinterest-ads'
|
|
| 'postgresql'
|
|
| 'reddit-ads'
|
|
| 's3-compatible'
|
|
| 'salesforce'
|
|
| 'slack'
|
|
| 'snapchat'
|
|
| 'snowflake'
|
|
| 'stripe'
|
|
| 'tiktok-ads'
|
|
| 'twilio'
|
|
| 'vercel'
|
|
}[]
|
|
payload?: any
|
|
} // integrationsLogic
|
|
addPendingNotification: (notification: PendingAlertNotification) => {
|
|
notification: PendingAlertNotification
|
|
}
|
|
clearPendingNotifications: () => {
|
|
value: true
|
|
}
|
|
createPendingInsightsFunctions: (
|
|
alertId: string,
|
|
alertName?: string
|
|
) => {
|
|
alertId: string
|
|
alertName: string | undefined
|
|
}
|
|
deleteExistingInsightsFunction: (insightsFunction: InsightsFunctionType) => {
|
|
insightsFunction: InsightsFunctionType
|
|
}
|
|
loadExistingInsightsFunctions: () => any
|
|
loadExistingInsightsFunctionsFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadExistingInsightsFunctionsSuccess: (
|
|
existingInsightsFunctions: InsightsFunctionType[],
|
|
payload?: any
|
|
) => {
|
|
existingInsightsFunctions: InsightsFunctionType[]
|
|
payload?: any
|
|
}
|
|
removePendingNotification: (index: number) => {
|
|
index: number
|
|
}
|
|
sendTestDelivery: () => any
|
|
sendTestDeliveryFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
sendTestDeliverySuccess: (
|
|
testDeliveryResult: AlertTestDeliveryResponseApi,
|
|
payload?: any
|
|
) => {
|
|
testDeliveryResult: AlertTestDeliveryResponseApi
|
|
payload?: any
|
|
}
|
|
setPendingNotifications: (notifications: PendingAlertNotification[]) => {
|
|
notifications: PendingAlertNotification[]
|
|
}
|
|
setSelectedSlackIntegrationId: (selectedSlackIntegrationId: number | null) => {
|
|
selectedSlackIntegrationId: number | null
|
|
}
|
|
setSelectedType: (selectedType: AlertNotificationType) => {
|
|
selectedType: AlertNotificationType
|
|
}
|
|
setSlackChannelValue: (slackChannelValue: string | null) => {
|
|
slackChannelValue: string | null
|
|
}
|
|
setWebhookUrl: (webhookUrl: string) => {
|
|
webhookUrl: string
|
|
}
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface alertNotificationLogicMeta {
|
|
key: string
|
|
__keaTypeGenInternalSelectorTypes: {
|
|
selectedSlackIntegration: (
|
|
slackIntegrations: IntegrationType[] | undefined,
|
|
selectedSlackIntegrationId: number | null
|
|
) => IntegrationType | undefined
|
|
}
|
|
}
|
|
|
|
export type alertNotificationLogicType = MakeLogicType<
|
|
alertNotificationLogicValues,
|
|
alertNotificationLogicActions,
|
|
AlertNotificationLogicProps,
|
|
alertNotificationLogicMeta
|
|
>
|
|
|
|
export const alertNotificationLogic = kea<alertNotificationLogicType>([
|
|
path(['lib', 'components', 'Alerts', 'alertNotificationLogic']),
|
|
props({} as AlertNotificationLogicProps),
|
|
key(({ alertId, loadDestinations = true }) => `${alertId ?? 'new'}-${loadDestinations ? 'full' : 'test-only'}`),
|
|
|
|
connect({
|
|
values: [projectLogic, ['currentProjectId'], integrationsLogic, ['integrationsLoading', 'slackIntegrations']],
|
|
actions: [integrationsLogic, ['loadIntegrations', 'loadIntegrationsFailure', 'loadIntegrationsSuccess']],
|
|
}),
|
|
|
|
actions({
|
|
addPendingNotification: (notification: PendingAlertNotification) => ({ notification }),
|
|
removePendingNotification: (index: number) => ({ index }),
|
|
clearPendingNotifications: true,
|
|
setPendingNotifications: (notifications: PendingAlertNotification[]) => ({ notifications }),
|
|
deleteExistingInsightsFunction: (insightsFunction: InsightsFunctionType) => ({ insightsFunction }),
|
|
createPendingInsightsFunctions: (alertId: string, alertName?: string) => ({ alertId, alertName }),
|
|
setSelectedType: (selectedType: AlertNotificationType) => ({ selectedType }),
|
|
setSelectedSlackIntegrationId: (selectedSlackIntegrationId: number | null) => ({
|
|
selectedSlackIntegrationId,
|
|
}),
|
|
setSlackChannelValue: (slackChannelValue: string | null) => ({ slackChannelValue }),
|
|
setWebhookUrl: (webhookUrl: string) => ({ webhookUrl }),
|
|
}),
|
|
|
|
reducers({
|
|
pendingNotifications: [
|
|
[] as PendingAlertNotification[],
|
|
{
|
|
addPendingNotification: (state, { notification }) => [...state, notification],
|
|
removePendingNotification: (state, { index }) => state.filter((_, i) => i !== index),
|
|
clearPendingNotifications: () => [],
|
|
setPendingNotifications: (_, { notifications }) => notifications,
|
|
},
|
|
],
|
|
integrationsFailed: [
|
|
false,
|
|
{
|
|
loadIntegrations: () => false,
|
|
loadIntegrationsFailure: () => true,
|
|
loadIntegrationsSuccess: () => false,
|
|
},
|
|
],
|
|
slackChannelValue: [
|
|
null as string | null,
|
|
{
|
|
setSlackChannelValue: (_, { slackChannelValue }) => slackChannelValue,
|
|
// Reset the input when switching destination type so stale values don't carry over.
|
|
setSelectedType: () => null,
|
|
setSelectedSlackIntegrationId: () => null,
|
|
},
|
|
],
|
|
selectedSlackIntegrationId: [
|
|
null as number | null,
|
|
{
|
|
setSelectedSlackIntegrationId: (_, { selectedSlackIntegrationId }) => selectedSlackIntegrationId,
|
|
},
|
|
],
|
|
webhookUrl: [
|
|
'' as string,
|
|
{
|
|
setWebhookUrl: (_, { webhookUrl }) => webhookUrl,
|
|
setSelectedType: () => '',
|
|
},
|
|
],
|
|
selectedType: [
|
|
ALERT_NOTIFICATION_TYPE_SLACK as AlertNotificationType,
|
|
{
|
|
setSelectedType: (_, { selectedType }) => selectedType,
|
|
},
|
|
],
|
|
existingInsightsFunctions: [
|
|
[] as InsightsFunctionType[],
|
|
{
|
|
// Optimistic removal so the item disappears immediately
|
|
deleteExistingInsightsFunction: (state, { insightsFunction }) => state.filter((hf) => hf.id !== insightsFunction.id),
|
|
},
|
|
],
|
|
}),
|
|
|
|
selectors({
|
|
selectedSlackIntegration: [
|
|
(s) => [s.slackIntegrations, s.selectedSlackIntegrationId],
|
|
(
|
|
slackIntegrations: IntegrationType[] | undefined,
|
|
selectedSlackIntegrationId: number | null
|
|
): IntegrationType | undefined =>
|
|
slackIntegrations?.find((integration) => integration.id === selectedSlackIntegrationId) ??
|
|
slackIntegrations?.[0],
|
|
],
|
|
}),
|
|
|
|
loaders(({ props, values }) => ({
|
|
existingInsightsFunctions: [
|
|
[] as InsightsFunctionType[],
|
|
{
|
|
loadExistingInsightsFunctions: async () => {
|
|
if (!props.alertId) {
|
|
return []
|
|
}
|
|
const response = await api.insightsFunctions.list({
|
|
types: ['internal_destination'],
|
|
filter_groups: [buildAlertFilterConfig(props.alertId)],
|
|
full: true,
|
|
})
|
|
return response.results
|
|
},
|
|
},
|
|
],
|
|
testDeliveryResult: [
|
|
null as AlertTestDeliveryResponseApi | null,
|
|
{
|
|
sendTestDelivery: async () => {
|
|
if (!props.alertId || values.currentProjectId === null) {
|
|
throw new Error('Save the alert before sending a test.')
|
|
}
|
|
return alertsTestDeliveryCreate(String(values.currentProjectId), props.alertId)
|
|
},
|
|
},
|
|
],
|
|
})),
|
|
|
|
listeners(({ actions, values, props }) => ({
|
|
loadIntegrationsSuccess: ({ integrations }) => {
|
|
const availableSlackIntegrations = integrations.filter((integration) => integration.kind === 'slack')
|
|
const selectedIntegrationIsAvailable = availableSlackIntegrations.some(
|
|
(integration) => integration.id === values.selectedSlackIntegrationId
|
|
)
|
|
|
|
if (!selectedIntegrationIsAvailable) {
|
|
actions.setSelectedSlackIntegrationId(availableSlackIntegrations[0]?.id ?? null)
|
|
}
|
|
if (availableSlackIntegrations.length === 0) {
|
|
actions.setSelectedType(ALERT_NOTIFICATION_TYPE_WEBHOOK)
|
|
}
|
|
|
|
// Drop any staged Slack notifications pointing at a workspace that's no longer
|
|
// connected — saving them would create a destination referencing a dead integration.
|
|
const availableIds = new Set(availableSlackIntegrations.map((integration) => integration.id))
|
|
const stillValidPending = values.pendingNotifications.filter(
|
|
(notification) =>
|
|
notification.type !== ALERT_NOTIFICATION_TYPE_SLACK ||
|
|
availableIds.has(notification.slackWorkspaceId)
|
|
)
|
|
if (stillValidPending.length !== values.pendingNotifications.length) {
|
|
actions.setPendingNotifications(stillValidPending)
|
|
}
|
|
},
|
|
deleteExistingInsightsFunction: async ({ insightsFunction }) => {
|
|
// Resolve the type from the closure up front — after the delete the InsightsFunction is
|
|
// removed from state, so we can't derive it later.
|
|
const type = notificationTypeFromTemplateId(insightsFunction.template_id)
|
|
await deleteWithUndo({
|
|
endpoint: `projects/${values.currentProjectId}/insights_functions`,
|
|
object: {
|
|
id: insightsFunction.id,
|
|
name: insightsFunction.name,
|
|
},
|
|
callback: (undo) => {
|
|
if (undo) {
|
|
actions.loadExistingInsightsFunctions()
|
|
return
|
|
}
|
|
// Skip on undo and on unrecognized templates so this only counts confirmed deletes with a known type.
|
|
if (type) {
|
|
insights.capture('insight alert destination deleted', {
|
|
alert_id: props.alertId,
|
|
type,
|
|
})
|
|
}
|
|
},
|
|
})
|
|
},
|
|
|
|
createPendingInsightsFunctions: async ({ alertId, alertName }) => {
|
|
const pending = values.pendingNotifications
|
|
if (pending.length === 0) {
|
|
return
|
|
}
|
|
|
|
const results = await Promise.allSettled(
|
|
pending.map((notification) => {
|
|
const payload = buildInsightsFunctionPayload(alertId, alertName, notification)
|
|
return api.insightsFunctions.create(payload)
|
|
})
|
|
)
|
|
|
|
// No `alert created`/`alert updated` event captures destination type, so this is emitted per created destination instead.
|
|
results.forEach((result, i) => {
|
|
if (result.status === 'fulfilled') {
|
|
insights.capture('insight alert destination created', {
|
|
alert_id: alertId,
|
|
type: pending[i].type,
|
|
})
|
|
}
|
|
})
|
|
|
|
const failures = results
|
|
.map((result, i) => ({ result, notification: pending[i] }))
|
|
.filter(
|
|
(item): item is { result: PromiseRejectedResult; notification: PendingAlertNotification } =>
|
|
item.result.status === 'rejected'
|
|
)
|
|
|
|
if (failures.length > 0) {
|
|
const labelForType = (type: AlertNotificationType): string =>
|
|
ALERT_NOTIFICATION_TYPE_OPTIONS.find((option) => option.value === type)?.label ?? type
|
|
failures.forEach(({ result, notification }) => {
|
|
console.error(
|
|
`Failed to create ${labelForType(notification.type)} alert notification`,
|
|
result.reason
|
|
)
|
|
})
|
|
const failedTypes = Array.from(
|
|
new Set(failures.map(({ notification }) => labelForType(notification.type)))
|
|
)
|
|
toast.error(
|
|
`Alert saved, but failed to create: ${failedTypes.join(', ')}. Reopen the alert to add them again.`
|
|
)
|
|
actions.setPendingNotifications(failures.map(({ notification }) => notification))
|
|
} else {
|
|
if (results.length > 0) {
|
|
toast.success(`${results.length} notification destination(s) created.`)
|
|
}
|
|
actions.clearPendingNotifications()
|
|
}
|
|
|
|
actions.loadExistingInsightsFunctions()
|
|
},
|
|
sendTestDeliverySuccess: ({ testDeliveryResult }) => {
|
|
const destinationCount = testDeliveryResult.destination_count
|
|
const emailCount = testDeliveryResult.email_recipient_count
|
|
const deliveryTargets: string[] = []
|
|
if (emailCount > 0) {
|
|
deliveryTargets.push(`${emailCount} email ${emailCount === 1 ? 'recipient' : 'recipients'}`)
|
|
}
|
|
if (destinationCount > 0) {
|
|
deliveryTargets.push(`${destinationCount} ${destinationCount === 1 ? 'destination' : 'destinations'}`)
|
|
}
|
|
if (testDeliveryResult.failed_delivery_channels.length > 0) {
|
|
toast.warning(
|
|
`Test delivery started for ${deliveryTargets.join(' and ')}, but failed for ${testDeliveryResult.failed_delivery_channels.join(' and ')}.`
|
|
)
|
|
return
|
|
}
|
|
toast.success(`Test delivery started for ${deliveryTargets.join(' and ')}.`)
|
|
},
|
|
sendTestDeliveryFailure: ({ errorObject }) => {
|
|
const detail = errorObject?.detail
|
|
toast.error(typeof detail === 'string' ? detail : 'Unable to send the test. Try again.')
|
|
},
|
|
})),
|
|
|
|
afterMount(({ actions, props }) => {
|
|
if (props.alertId && props.loadDestinations !== false) {
|
|
actions.loadExistingInsightsFunctions()
|
|
}
|
|
}),
|
|
])
|