diff --git a/apps/dbagent/.env.example b/apps/dbagent/.env.example index 7a206fb9..0610255e 100644 --- a/apps/dbagent/.env.example +++ b/apps/dbagent/.env.example @@ -1,4 +1,5 @@ DATABASE_URL='postgres://dbagent:changeme@localhost:5432/dbagent' OPENAI_API_KEY= DEEPSEEK_API_KEY= -ANTHROPIC_API_KEY= \ No newline at end of file +ANTHROPIC_API_KEY= +PUBLIC_URL='http://localhost:4001' \ No newline at end of file diff --git a/apps/dbagent/package.json b/apps/dbagent/package.json index d9c58075..df97de79 100644 --- a/apps/dbagent/package.json +++ b/apps/dbagent/package.json @@ -28,6 +28,7 @@ "@vercel/functions": "^2.0.0", "ai": "^4.1.45", "bytes": "^3.1.2", + "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cron-parser": "^5.0.4", @@ -52,6 +53,7 @@ "@internal/eslint-config": "workspace:*", "@internal/tsconfig": "workspace:*", "@types/bytes": "^3.1.5", + "@types/canvas-confetti": "^1.9.0", "@types/pg": "^8.11.11", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", diff --git a/apps/dbagent/server.ts b/apps/dbagent/server.ts new file mode 100644 index 00000000..04db8b5e --- /dev/null +++ b/apps/dbagent/server.ts @@ -0,0 +1,16 @@ +/* eslint-disable no-process-env */ +import { createServer } from 'http'; +import next from 'next'; + +const port = process.env.PORT || 4001; +const dev = process.env.NODE_ENV !== 'production'; +const app = next({ dev }); +const handle = app.getRequestHandler(); + +void app.prepare().then(() => { + createServer((req, res) => { + void handle(req, res); + }).listen(port, () => { + console.log(`> Ready on http://localhost:${port}`); + }); +}); diff --git a/apps/dbagent/src/app/(main)/layout.tsx b/apps/dbagent/src/app/(main)/layout.tsx index 045bc5b6..31f8f812 100644 --- a/apps/dbagent/src/app/(main)/layout.tsx +++ b/apps/dbagent/src/app/(main)/layout.tsx @@ -1,7 +1,10 @@ +'use server'; import { BelowHeaderBar, HeaderBar } from '~/app/components/ui/header-bar'; +import { getCompletedTaskPercentage } from '~/components/onboarding/actions'; import { SideNav } from '../components/ui/side-nav'; export default async function Layout({ children }: { children: React.ReactNode }) { + const onboardingComplete = await getCompletedTaskPercentage(); return ( <>
@@ -10,7 +13,7 @@ export default async function Layout({ children }: { children: React.ReactNode }
- +
{children}
diff --git a/apps/dbagent/src/app/(main)/monitoring/schedule/[id]/page.tsx b/apps/dbagent/src/app/(main)/monitoring/schedule/[id]/page.tsx index 2b4a1fbe..a6f52af2 100644 --- a/apps/dbagent/src/app/(main)/monitoring/schedule/[id]/page.tsx +++ b/apps/dbagent/src/app/(main)/monitoring/schedule/[id]/page.tsx @@ -2,17 +2,18 @@ import { actionListConnections } from '~/components/connections/actions'; import { actionListPlaybooks } from '~/components/monitoring/actions'; import { ScheduleForm } from '~/components/monitoring/schedule-form'; -export default async function Page({ params }: { params: { id: string; connection?: string } }) { +interface PageParams { + id: string; + connection?: string; +} + +export default async function Page({ params }: { params: Promise }) { const playbooks = await actionListPlaybooks(); const connections = await actionListConnections(); + const { id, connection } = await params; return (
- +
); } diff --git a/apps/dbagent/src/app/(main)/start/connect/edit/[id]/page.tsx b/apps/dbagent/src/app/(main)/start/connect/edit/[id]/page.tsx index dc38bfd7..5bffa4b9 100644 --- a/apps/dbagent/src/app/(main)/start/connect/edit/[id]/page.tsx +++ b/apps/dbagent/src/app/(main)/start/connect/edit/[id]/page.tsx @@ -1,10 +1,11 @@ import { ConnectionForm } from '~/components/connections/connection-form'; -export default async function EditConnection({ params }: { params: { id: number } }) { +export default async function EditConnection({ params }: { params: Promise<{ id: number }> }) { + const { id } = await params; return (

Edit Connection

- +
); } diff --git a/apps/dbagent/src/app/(main)/start/notifications/page.tsx b/apps/dbagent/src/app/(main)/start/notifications/page.tsx new file mode 100644 index 00000000..52118d7b --- /dev/null +++ b/apps/dbagent/src/app/(main)/start/notifications/page.tsx @@ -0,0 +1,12 @@ +'use server'; + +import { SlackIntegration } from '~/components/slack-integration/slack-integration'; + +export default async function Page() { + return ( +
+

Collect info about your database

+ +
+ ); +} diff --git a/apps/dbagent/src/app/api/chat/route.ts b/apps/dbagent/src/app/api/chat/route.ts index d768310f..d03455fa 100644 --- a/apps/dbagent/src/app/api/chat/route.ts +++ b/apps/dbagent/src/app/api/chat/route.ts @@ -6,7 +6,7 @@ import { getTargetDbConnection } from '~/lib/targetdb/db'; export const runtime = 'nodejs'; export const maxDuration = 30; -export function errorHandler(error: unknown) { +function errorHandler(error: unknown) { if (error == null) { return 'unknown error'; } diff --git a/apps/dbagent/src/app/components/ui/side-nav.tsx b/apps/dbagent/src/app/components/ui/side-nav.tsx index 7fac726c..667d8ce5 100644 --- a/apps/dbagent/src/app/components/ui/side-nav.tsx +++ b/apps/dbagent/src/app/components/ui/side-nav.tsx @@ -3,28 +3,46 @@ import { cn, Input } from '@internal/components'; import { ActivityIcon, + AlarmClock, BotMessageSquare, CloudIcon, DatabaseIcon, - GitBranch, PlugIcon, Server, ZapIcon } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useEffect, useState } from 'react'; interface SideNavProps { className?: string; + onboardingComplete: number; } -export function SideNav({ className }: SideNavProps) { +export function SideNav({ className, onboardingComplete }: SideNavProps) { const pathname = usePathname(); + const [onboardingCompleteState, setOnboardingComplete] = useState(onboardingComplete); const isActive = (path: string) => { return pathname === path ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'; }; + useEffect(() => { + const handleOnboardingStatus = (e: CustomEvent) => { + // Update your onboarding complete state here + setOnboardingComplete(e.detail.completed); + }; + + window.addEventListener('onboardingStatusChanged', handleOnboardingStatus as EventListener); + + return () => { + window.removeEventListener('onboardingStatusChanged', handleOnboardingStatus as EventListener); + }; + }, []); + + console.log('onboardingComplete', onboardingCompleteState); + return (
@@ -35,7 +53,7 @@ export function SideNav({ className }: SideNavProps) { className={cn('flex items-center gap-2 rounded-md px-3 py-2 text-sm', isActive(`/start`))} > - Starter guide + Starter guide {onboardingCompleteState ? `(${onboardingCompleteState}%)` : ''}
}> + + + ); +} + +function ChatsUIContent({ connections }: { connections: DbConnection[] }) { const searchParams = useSearchParams(); const [selectedChatId, setSelectedChatId] = useState(null); const [chats, setChats] = useState(mockChats); diff --git a/apps/dbagent/src/components/monitoring/schedules-table.tsx b/apps/dbagent/src/components/monitoring/schedules-table.tsx index 6304eab0..2b71f5bc 100644 --- a/apps/dbagent/src/components/monitoring/schedules-table.tsx +++ b/apps/dbagent/src/components/monitoring/schedules-table.tsx @@ -100,8 +100,8 @@ export function MonitoringScheduleTable({ connections }: { connections: DbConnec - Playbook Database + Playbook Schedule Status Last Run diff --git a/apps/dbagent/src/components/onboarding/actions.ts b/apps/dbagent/src/components/onboarding/actions.ts index f6c1ff22..54b2f754 100644 --- a/apps/dbagent/src/components/onboarding/actions.ts +++ b/apps/dbagent/src/components/onboarding/actions.ts @@ -3,6 +3,7 @@ import { getClusters } from '~/lib/db/clusters'; import { getDefaultConnection } from '~/lib/db/connections'; import { getDbInfo } from '~/lib/db/dbinfo'; +import { getIntegration } from '~/lib/db/integrations'; // Server action to get completed tasks export async function getCompletedTasks(): Promise { @@ -24,5 +25,15 @@ export async function getCompletedTasks(): Promise { completedTasks.push('cloud'); } + const slack = await getIntegration('slack'); + if (slack) { + completedTasks.push('notifications'); + } + return completedTasks; } + +export async function getCompletedTaskPercentage(): Promise { + const completedTasks = await getCompletedTasks(); + return Math.round((completedTasks.length / 4) * 100); +} diff --git a/apps/dbagent/src/components/onboarding/onboarding.tsx b/apps/dbagent/src/components/onboarding/onboarding.tsx index a4cfc1f4..dce39dd0 100644 --- a/apps/dbagent/src/components/onboarding/onboarding.tsx +++ b/apps/dbagent/src/components/onboarding/onboarding.tsx @@ -1,12 +1,45 @@ 'use client'; -import { Activity, Database, GitBranch, Server, TowerControlIcon } from 'lucide-react'; +import { Button } from '@internal/components'; +import confetti from 'canvas-confetti'; +import { Activity, Check, Database, GitBranch, Server } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { getCompletedTasks } from './actions'; import { OnboardingProgress } from './onboarding-progress'; import { OnboardingTask } from './onboarding-task'; +export const onboardingTasks = [ + { + id: 'connect', + title: 'Connect to Database', + description: `Add at least a database connection. You'd normally configure your production database connection here. Don't worry, I won't run any destructive queries.`, + icon: , + navigateTo: '/start/connect' + }, + { + id: 'collect', + title: 'Collect Database Info', + description: `Let's check that I have proper access and that I can collect some basic information about your database.`, + icon: , + navigateTo: '/start/collect' + }, + { + id: 'cloud', + title: 'Connect cloud management', + description: `Use an integration to allow me to read your relevant instance and observability data.`, + icon: , + navigateTo: '/start/cloud' + }, + { + id: 'notifications', + title: 'Setup Slack notifications', + description: 'Configure a Slack integration so I can notify you if I find any issues with your database.', + icon: , + navigateTo: '/start/notifications' + } +]; + export function Onboarding() { const router = useRouter(); const [completedTasks, setCompletedTasks] = useState([]); @@ -14,54 +47,26 @@ export function Onboarding() { useEffect(() => { getCompletedTasks() .then((tasks) => { + if (tasks.length === onboardingTasks.length && completedTasks.length < onboardingTasks.length) { + void confetti({ + particleCount: 200, + spread: 2000, + origin: { y: 0.3 } + }); + } setCompletedTasks(tasks); + // Dispatch a custom event when tasks are loaded + window.dispatchEvent( + new CustomEvent('onboardingStatusChanged', { + detail: { completed: Math.round((tasks.length / onboardingTasks.length) * 100) } + }) + ); }) .catch((error) => { console.error('Failed to load completed tasks:', error); }); }, []); - const tasks = [ - { - id: 'connect', - title: 'Connect to Database', - description: `Add at least a database connection. You'd normally configure your production database connection here. Don't worry, I won't run any destructive queries.`, - icon: , - navigateTo: '/start/connect' - }, - { - id: 'collect', - title: 'Collect Database Info', - description: `Let's check that I have proper access and that I can collect some basic information about your database.`, - icon: , - navigateTo: '/start/collect' - }, - { - id: 'cloud', - title: 'Connect cloud management', - description: `Use an integration to allow me to read your relevant instance and observability data.`, - icon: , - navigateTo: '/start/cloud' - }, - { - id: 'report', - title: 'Get an initial assessment', - description: - 'I will get an initial assessment of your database, instance/cluster type, main settings and activity and provide you with an initial report.', - icon: , - buttonText: 'Get initial assessment', - navigateTo: '/chats?start=report' - }, - { - id: 'environments', - title: 'Setup staging and dev environments', - description: - 'If I can use a staging environment that supports copy-on-write branches, I will be able to test changes in a safe environment on my own.', - icon: , - navigateTo: '/start/environments' - } - ]; - const handleTaskAction = async (navigateTo: string) => { router.push(navigateTo); }; @@ -78,21 +83,45 @@ export function Onboarding() {

- +
- {tasks.map((task) => ( + {onboardingTasks.map((task) => ( handleTaskAction(task.navigateTo)} /> ))}
+ {completedTasks.length === onboardingTasks.length && ( +
+
+
+ +
+
+
+

Congratulations! All tasks completed

+

+ Great job! You've completed all the setup tasks. I'm now ready to help you monitor and + optimize your database. +

+
+
+ + +
+
+
+
+ )} ); diff --git a/apps/dbagent/src/components/slack-integration/actions.ts b/apps/dbagent/src/components/slack-integration/actions.ts new file mode 100644 index 00000000..70b77458 --- /dev/null +++ b/apps/dbagent/src/components/slack-integration/actions.ts @@ -0,0 +1,28 @@ +'use server'; + +import { getIntegration, saveIntegration, SlackIntegration } from '~/lib/db/integrations'; + +export async function saveWebhookUrl(webhookUrl: string): Promise<{ success: boolean; message: string }> { + // TODO: Implement actual saving logic here + // This is a placeholder for demonstration purposes + const success = webhookUrl && webhookUrl.startsWith('https://hooks.slack.com/'); + + if (!success) { + // In a real implementation, you would save the webhook URL to your database or configuration + return { success: false, message: 'Invalid webhook URL' }; + } + + try { + await saveIntegration('slack', { webhookUrl }); + } catch (error) { + console.error('Failed to save webhook URL:', error); + return { success: false, message: `Failed to save webhook URL.` }; + } + + console.log('Webhook URL saved:', webhookUrl); + return { success: true, message: 'Webhook URL saved successfully' }; +} + +export async function getWebhookUrl(): Promise { + return await getIntegration('slack'); +} diff --git a/apps/dbagent/src/components/slack-integration/slack-integration.tsx b/apps/dbagent/src/components/slack-integration/slack-integration.tsx new file mode 100644 index 00000000..74a7e006 --- /dev/null +++ b/apps/dbagent/src/components/slack-integration/slack-integration.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, + toast +} from '@internal/components'; +import { AlertCircle } from 'lucide-react'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { getWebhookUrl, saveWebhookUrl } from './actions'; + +export function SlackIntegration() { + const [isLoading, setIsLoading] = useState(true); + + const { + register, + handleSubmit, + formState: { errors }, + setValue + } = useForm({ + defaultValues: { + webhookUrl: '' + } + }); + + useEffect(() => { + const fetchWebhookUrl = async () => { + try { + const data = await getWebhookUrl(); + if (data) { + setValue('webhookUrl', data.webhookUrl); + } + } catch (error) { + toast('Failed to load existing webhook configuration'); + } finally { + setIsLoading(false); + } + }; + + void fetchWebhookUrl(); + }, [setValue]); + + const onSubmit = async (data: { webhookUrl: string }) => { + try { + const response = await saveWebhookUrl(data.webhookUrl); + if (response.success) { + toast('Slack webhook URL saved successfully'); + } else { + toast(`Error saving Slack webhook URL. ${response.message}`); + } + } catch (error) { + toast(`Failed to save Slack webhook URL.`); + } + }; + + return ( +
+ + + Notifications + Configure slack notifications + + +
+ + + Create a Slack Webhook + + To create an incoming webhook for posting to your Slack workspace,{' '} + + follow this guide + + . It only takes a few minutes to set up. + + +
+ +
+
+
+ + + {errors.webhookUrl &&

{errors.webhookUrl.message}

} +
+ + +
+ +
+
+
+ ); +} diff --git a/apps/dbagent/src/lib/db/integrations.ts b/apps/dbagent/src/lib/db/integrations.ts index 75a6606b..24f7db00 100644 --- a/apps/dbagent/src/lib/db/integrations.ts +++ b/apps/dbagent/src/lib/db/integrations.ts @@ -6,8 +6,13 @@ export type AwsIntegration = { region: string; }; +export type SlackIntegration = { + webhookUrl: string; +}; + type IntegrationModules = { aws: AwsIntegration; + slack: SlackIntegration; }; export async function saveIntegration(name: T, data: IntegrationModules[T]) { @@ -23,7 +28,7 @@ export async function saveIntegration(name: } export async function getIntegration( - name: string + name: T ): Promise { const client = await pool.connect(); try { diff --git a/apps/dbagent/src/lib/env/general.ts b/apps/dbagent/src/lib/env/general.ts new file mode 100644 index 00000000..ac858963 --- /dev/null +++ b/apps/dbagent/src/lib/env/general.ts @@ -0,0 +1,11 @@ +/* eslint-disable no-process-env */ + +import { z } from 'zod'; + +const schema = z.object({ + PUBLIC_URL: z.string().default('http://localhost:4001') +}); + +export const env = schema.parse({ + PUBLIC_URL: process.env.PUBLIC_URL +}); diff --git a/apps/dbagent/src/lib/monitoring/runner.ts b/apps/dbagent/src/lib/monitoring/runner.ts index 6a7b916c..f076bb7a 100644 --- a/apps/dbagent/src/lib/monitoring/runner.ts +++ b/apps/dbagent/src/lib/monitoring/runner.ts @@ -1,7 +1,9 @@ -import { generateText } from 'ai'; +import { CoreMessage, generateObject, generateText } from 'ai'; +import { z } from 'zod'; import { Schedule } from '~/lib/db/schedules'; import { getModelInstance, getTools, monitoringSystemPrompt } from '../ai/aidba'; import { getConnection } from '../db/connections'; +import { sendScheduleNotification } from '../notifications/slack-webhook'; import { getTargetDbConnection } from '../targetdb/db'; export async function runSchedule(schedule: Schedule) { @@ -16,23 +18,56 @@ export async function runSchedule(schedule: Schedule) { const modelInstance = getModelInstance(schedule.model); + const messages = [ + { + role: 'user', + content: `Run this playbook: ${schedule.playbook}` + } + ] as CoreMessage[]; + + if (schedule.additionalInstructions) { + messages.push({ + role: 'user', + content: schedule.additionalInstructions + }); + } + const result = await generateText({ model: modelInstance, system: monitoringSystemPrompt, - messages: [ - { - role: 'user', - content: `Run this playbook: ${schedule.playbook}` - }, - { - role: 'user', - content: schedule.additionalInstructions ?? '' - } - ], + messages: messages, tools: await getTools(connection, targetClient), maxSteps: 20 }); - console.log(result); console.log(result.text); + + const notificationResult = await generateObject({ + model: modelInstance, + schema: z.object({ + summary: z.string(), + notificationLevel: z.enum(['info', 'warning', 'alert']) + }), + prompt: `Decide a level of notification for the following + result of a playbook run. Choose one of these levels: + + info: Everything is fine, no action is needed. + warning: Some issues were found, but nothing that requires immediate attention. + alert: We need immediate action. + + Also provide a one sentence summary of the result. It can be something like "No issues found" or "Some issues were found". + + Playbook: ${schedule.playbook} + Result: ${result.text}` + }); + + console.log(JSON.stringify(notificationResult.object, null, 2)); + + await sendScheduleNotification( + schedule, + connection, + notificationResult.object.notificationLevel, + notificationResult.object.summary, + result.text + ); } diff --git a/apps/dbagent/src/lib/notifications/slack-webhook.ts b/apps/dbagent/src/lib/notifications/slack-webhook.ts new file mode 100644 index 00000000..de4807ef --- /dev/null +++ b/apps/dbagent/src/lib/notifications/slack-webhook.ts @@ -0,0 +1,111 @@ +import { DbConnection } from '../db/connections'; +import { getIntegration } from '../db/integrations'; +import { Schedule } from '../db/schedules'; +import { env } from '../env/general'; + +export type NotificationLevel = 'info' | 'warning' | 'alert'; + +export async function sendScheduleNotification( + schedule: Schedule, + connection: DbConnection, + level: NotificationLevel, + title: string, + message: string +) { + const slack = await getIntegration('slack'); + if (!slack) { + console.error('No Slack integration configured.'); + return; + } + + // Format message for Slack "markdown" + message = message + .replace(/^### (.*$)/gm, '*$1*') // h3 headers to bold + .replace(/^## (.*$)/gm, '*$1*') // h2 headers to bold + .replace(/^# (.*$)/gm, '*$1*') // h1 headers to bold + .replace(/\*\*(.*?)\*\*/g, '*$1*') // bold + .replace(/__(.*?)__/g, '_$1_') // underline/italics + .replace(/\[(.*?)\]\((.*?)\)/g, '<$2|$1>') // links + .replace(/`{3}([\s\S]*?)`{3}/g, '```$1```') // code blocks + .replace(/`([^`]+)`/g, '`$1`'); // inline code + + const slackEmoji = level === 'info' ? ':white_check_mark:' : level === 'warning' ? ':warning:' : ':alert:'; + + const slackBlocks = { + blocks: [ + { + type: 'section', + text: { + type: 'plain_text', + emoji: true, + text: slackEmoji + ' ' + title + } + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: '*Database:*\n' + connection.name + }, + { + type: 'mrkdwn', + text: '*Playbook:*\n' + schedule.playbook + }, + { + type: 'mrkdwn', + text: '*Model:*\n' + schedule.model + }, + { + type: 'mrkdwn', + text: '*Schedule:*\n' + (schedule.scheduleType === 'cron' ? schedule.cronExpression : 'Automatic') + } + ] + }, + { + type: 'divider' + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: message + } + }, + { + type: 'divider' + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `I'll do the next check at *${schedule.nextRun}*` + } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { + type: 'plain_text', + text: 'View Schedule Settings' + }, + url: `${env.PUBLIC_URL}/monitoring/schedule/${schedule.id}` + } + ] + } + ] + }; + + console.log(JSON.stringify(slackBlocks, null, 2)); + + const response = await fetch(slack.webhookUrl, { + method: 'POST', + body: JSON.stringify(slackBlocks) + }); + + if (!response.ok) { + console.error('Failed to send Slack notification:', await response.text()); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50a8fd57..6b246d2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: bytes: specifier: ^3.1.2 version: 3.1.2 + canvas-confetti: + specifier: ^1.9.3 + version: 1.9.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -156,6 +159,9 @@ importers: '@types/bytes': specifier: ^3.1.5 version: 3.1.5 + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 '@types/pg': specifier: ^8.11.11 version: 8.11.11 @@ -2093,6 +2099,9 @@ packages: '@types/bytes@3.1.5': resolution: {integrity: sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/chroma-js@3.1.1': resolution: {integrity: sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA==} @@ -2532,6 +2541,9 @@ packages: caniuse-lite@1.0.30001700: resolution: {integrity: sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==} + canvas-confetti@1.9.3: + resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6917,6 +6929,8 @@ snapshots: '@types/bytes@3.1.5': {} + '@types/canvas-confetti@1.9.0': {} + '@types/chroma-js@3.1.1': {} '@types/d3-array@3.2.1': {} @@ -7438,6 +7452,8 @@ snapshots: caniuse-lite@1.0.30001700: {} + canvas-confetti@1.9.3: {} + ccount@2.0.1: {} chai@5.2.0: