├── .cursorrules ├── .github └── workflows │ ├── sync-live.yml │ ├── tinybird-cd.yml │ └── tinybird-ci.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── README.md ├── TEMPLATE.md ├── assets └── screenshot.png ├── dashboard └── ai-analytics │ ├── .gitignore │ ├── README.md │ ├── components.json │ ├── eslint.config.mjs │ ├── next.config.ts │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── postcss.config.mjs │ ├── public │ ├── file.svg │ ├── globe.svg │ ├── next.svg │ ├── onboarding │ │ ├── costcalculator.mp4 │ │ ├── filterchips.mp4 │ │ ├── llmcalls.mp4 │ │ └── vectorsearch.mp4 │ ├── vercel.svg │ └── window.svg │ ├── src │ ├── app │ │ ├── api │ │ │ ├── extract-cost-parameters │ │ │ │ └── route.ts │ │ │ ├── generate-embedding │ │ │ │ └── route.ts │ │ │ └── search │ │ │ │ └── route.ts │ │ ├── components │ │ │ ├── ApiKeyInput.tsx │ │ │ ├── CostPredictionModal.tsx │ │ │ ├── CustomBarList.tsx │ │ │ ├── CustomTooltip.tsx │ │ │ ├── DataTable.tsx │ │ │ ├── DateRangeSelector.tsx │ │ │ ├── FilterChips.tsx │ │ │ ├── MetricsCards.tsx │ │ │ ├── ResizableSplitView.tsx │ │ │ ├── RootLayoutContent.tsx │ │ │ ├── SignInModal.tsx │ │ │ ├── SparkChart.tsx │ │ │ ├── TabbedPane.tsx │ │ │ ├── TimeseriesChart.tsx │ │ │ ├── TopBar.tsx │ │ │ ├── UserFilterChip.tsx │ │ │ └── icons │ │ │ │ └── index.tsx │ │ ├── constants.ts │ │ ├── containers │ │ │ ├── DataTableContainer.tsx │ │ │ ├── SparkChartContainer.tsx │ │ │ └── TimeseriesChartContainer.tsx │ │ ├── context │ │ │ ├── ModalContext.tsx │ │ │ └── OnboardingContext.tsx │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── globals.css_deleteme │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── settings │ │ │ └── page.tsx │ ├── components │ │ ├── RibbonsWrapper.tsx │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── floating-notification.tsx │ │ │ ├── label.tsx │ │ │ ├── onboarding-modal.tsx │ │ │ ├── popover.tsx │ │ │ ├── ribbons.tsx │ │ │ └── select.tsx │ ├── hooks │ │ ├── useKeyboardShortcut.tsx │ │ └── useTinybirdData.ts │ ├── lib │ │ ├── dateUtils.ts │ │ ├── dimensions.ts │ │ ├── tinybird-utils.ts │ │ ├── tinybird-wrapper.ts │ │ ├── user-hash.ts │ │ └── utils.ts │ ├── middleware.ts │ ├── providers │ │ └── TinybirdProvider.tsx │ ├── services │ │ └── tinybird.ts │ └── stores │ │ └── apiKeyStore.ts │ ├── tailwind.config.js │ ├── tailwind.config.js_deleteme │ └── tsconfig.json └── tinybird ├── .gitignore ├── README.md ├── datasources └── llm_events.datasource ├── endpoints ├── generic_counter.pipe ├── llm_dimensions.pipe ├── llm_messages.pipe └── llm_usage.pipe ├── fixtures ├── llm_events.ndjson └── llm_events.sql └── mock ├── generate-llm-events.js ├── package-lock.json └── package.json /.github/workflows/sync-live.yml: -------------------------------------------------------------------------------- 1 | name: Sync live demo branch with main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | sync: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Configure Git 18 | run: | 19 | git config user.name "github-actions[bot]" 20 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 21 | 22 | - name: Sync main into live 23 | run: | 24 | git checkout live 25 | git merge origin/main --no-edit 26 | git push origin live 27 | -------------------------------------------------------------------------------- /.github/workflows/tinybird-cd.yml: -------------------------------------------------------------------------------- 1 | name: Tinybird - CD Workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths: 9 | - 'tinybird/**' 10 | 11 | concurrency: ${{ github.workflow }}-${{ github.ref }} 12 | 13 | env: 14 | TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} 15 | TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} 16 | 17 | jobs: 18 | deploy: 19 | runs-on: ubuntu-latest 20 | defaults: 21 | run: 22 | working-directory: 'tinybird' 23 | services: 24 | tinybird: 25 | image: tinybirdco/tinybird-local:beta 26 | ports: 27 | - 7181:7181 28 | steps: 29 | - uses: actions/checkout@v3 30 | 31 | - name: Install Tinybird CLI 32 | run: curl https://tinybird.co | sh 33 | 34 | - name: Build project 35 | run: tb build 36 | 37 | - name: Verify deployment 38 | run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy --check 39 | 40 | - name: Deploy to cloud 41 | run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy -------------------------------------------------------------------------------- /.github/workflows/tinybird-ci.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Tinybird - CI Workflow 3 | 4 | on: 5 | workflow_dispatch: 6 | pull_request: 7 | branches: 8 | - main 9 | - master 10 | types: [opened, reopened, labeled, unlabeled, synchronize] 11 | paths: 12 | - 'tinybird/**' 13 | 14 | concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} 15 | 16 | env: 17 | TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} 18 | TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} 19 | 20 | jobs: 21 | ci: 22 | runs-on: ubuntu-latest 23 | defaults: 24 | run: 25 | working-directory: 'tinybird' 26 | services: 27 | tinybird: 28 | image: tinybirdco/tinybird-local:beta 29 | ports: 30 | - 7181:7181 31 | steps: 32 | - uses: actions/checkout@v3 33 | - name: Install Tinybird CLI 34 | run: curl https://tinybird.co | sh 35 | - name: Build project 36 | run: tb build 37 | - name: Test project 38 | run: tb test run 39 | - name: Deployment check 40 | run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy --check 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | tinybird/mock/*.ndjson 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | All notable changes to this project will be documented in this file. 2 | 3 | Types of changes: 4 | 5 | - `Added` for new features. 6 | - `Changed` for changes in existing functionality. 7 | - `Deprecated` for soon-to-be removed features. 8 | - `Fixed` for any bug fixes. 9 | - `Removed` for now removed features. 10 | - `Security` in case of vulnerabilities. 11 | - `Fixed` Improve error feedback when Explain feature fails 12 | 13 | 2025-04-09 14 | ========== 15 | 16 | - Added: Add `Quick start` to the onboarding modal. Learn how to instrument, deploy and use the hosted LLM tracker. 17 | 18 | 2025-04-08 19 | ========== 20 | 21 | - Added: Support a `token` parameter so you can use the hosted application at `llm-tracker.tinybird.live` with your own Tinybird workspace: 22 | 23 | ``` 24 | curl https://tinybird.co | sh 25 | tb login 26 | tb --cloud deploy --template https://github.com/tinybirdco/llm-performance-tracker/tree/main/tinybird 27 | tb --cloud token copy read_pipes && TINYBIRD_TOKEN=$(pbpaste) 28 | open https://llm-tracker.tinybird.live\?token\=$TOKEN 29 | ``` 30 | 31 | See [how to instrument your LLM calls](https://github.com/tinybirdco/llm-performance-tracker?tab=readme-ov-file#instrumentation) and you are done! 32 | 33 | 2025-04-07 34 | ========== 35 | 36 | - Added: Live demo mode. When setting your OpenAI key you can filter your own LLM calls from the application. Use the AI cost calculator or the "Ask AI..." filter and click `Your LLM calls` (no personal data is saved). 37 | 38 | [![LLM Calls Demo](https://img.youtube.com/vi/dF0kCYdf7QA/0.jpg)](https://youtu.be/dF0kCYdf7QA) 39 | 40 | - Added: Support multiple selections to compare 41 | - Added: Add loading states 42 | - Added: Added a component to resize the results table 43 | - Added: Added a component to choose visible columns in the result table 44 | - Fixed: Changing tabs does not reload the whole page 45 | - Changed: Show less columns by default in the data table 46 | - Changed: Update the floating component 47 | - Changed: Reset table on clear input search 48 | - Changed: AI cost calculator requests on clicking an example query 49 | 50 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome! Feel free to open issues or submit pull requests. 4 | 5 | Thanks for your support! 🚀 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LLM Performance Tracker 2 | 3 | This is a template for an LLM performance tracker dashboard and cost calculator. It is built with Next.js and [Tinybird](https://tinybird.co). 4 | 5 | ![LLM Performance Tracker Dashboard](./assets/screenshot.png) 6 | 7 | Use this template to bootstrap a multi-tenant, user-facing LLM analytics dashboard and cost calculator. 8 | 9 | Check the [demo video](https://youtu.be/34AF33EysTg) 10 | 11 | Features: 12 | 13 | - Track LLM costs, requests, tokens and duration by model, provider, organization, project, environment and user 14 | - Multi-tenant user-facing dashboard 15 | - AI cost calculator 16 | - Vector search 17 | - Ask AI integration 18 | 19 | Fork it and make it your own! You can track your own metrics and dimensions. 20 | 21 | Stack: 22 | 23 | - [Next.js](https://nextjs.org/) - Application 24 | - [Tinybird](https://tinybird.co) - Analytics 25 | - [OpenAI](https://openai.com/) - AI features 26 | - [Vercel AI SDK](https://sdk.vercel.ai/docs/introduction) - AI features 27 | - [Vercel](https://sdk.vercel.ai/docs/introduction) - Application deployment 28 | - [Clerk](https://clerk.com/) - User management and auth 29 | - [Tremor](https://tremor.so/) - Charts 30 | 31 | ## Live Demo 32 | 33 | - https://llm-tracker.tinybird.live 34 | 35 | ## Quick Start 36 | 37 | Deploy the template, instrument and use the hosted version to track. 38 | 39 | ### Deploy 40 | 41 | ```bash 42 | # install the tinybird CLI 43 | curl https://tinybird.co | sh 44 | 45 | # select or create a new workspace 46 | tb login 47 | 48 | # deploy the template 49 | tb --cloud deploy --template https://github.com/tinybirdco/llm-performance-tracker/tree/main/tinybird 50 | ``` 51 | 52 | ### Instrumentation 53 | 54 | Send your data to Tinybird using the [Events API](https://www.tinybird.co/docs/get-data-in/ingest-apis/events-api). Some examples: 55 | 56 | - [LiteLLM (Python)](https://www.tinybird.co/docs/get-data-in/guides/ingest-litellm) 57 | - [Vercel AI SDK (TypeScript)](https://www.tinybird.co/docs/get-data-in/guides/ingest-vercel-ai-sdk) 58 | 59 | ### Use the hosted app 60 | 61 | ```bash 62 | # copy the token to the clipboard 63 | tb --cloud token copy read_pipes && TINYBIRD_TOKEN=$(pbpaste) 64 | 65 | # use the hosted dashboard with your data 66 | open https://llm-tracker.tinybird.live\?token\=$TINYBIRD_TOKEN 67 | ``` 68 | 69 | ## Build and deploy your own LLM tracker 70 | 71 | Get started by forking the GitHub repository and then customizing it to your needs. 72 | 73 | Start Tinybird locally: 74 | 75 | ``` 76 | curl https://tinybird.co | sh 77 | cd tinybird 78 | tb local start 79 | tb login 80 | tb dev 81 | token ls # copy the read_pipes token 82 | ``` 83 | 84 | Configure the Next.js application: 85 | 86 | ``` 87 | cd dashboard/ai-analytics 88 | cp .env.example .env 89 | Edit the .env file with your Tinybird API key and other configuration. 90 | ``` 91 | 92 | ``` 93 | NEXT_PUBLIC_TINYBIRD_API_URL=http://localhost:7181 94 | # read_pipes token 95 | NEXT_PUBLIC_TINYBIRD_API_KEY= 96 | ``` 97 | 98 | Start the Next.js application: 99 | 100 | ``` 101 | cd dashboard/ai-analytics 102 | npm install 103 | npm run dev 104 | ``` 105 | 106 | Open the application in your browser: 107 | 108 | ``` 109 | http://localhost:3000 110 | ``` 111 | 112 | ## Deployment 113 | 114 | - Fork and connect this repository to Vercel. 115 | - Set the environment variables in Vercel. 116 | - Configure the [CI/CD GitHub actions](https://github.com/tinybirdco/ai-analytics-template/tree/main/.github/workflows) to deploy to Tinybird. 117 | 118 | ## Multi-tenancy 119 | 120 | Create a Clerk project and set up these environment variables in your Next.js application: 121 | 122 | ``` 123 | # workspace ID for multi-tenant JWT tokens 124 | TINYBIRD_WORKSPACE_ID= 125 | # workspace admin token for multi-tenant JWT tokens 126 | TINYBIRD_JWT_SECRET= 127 | 128 | # Clerk publishable key 129 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 130 | # Clerk secret key 131 | CLERK_SECRET_KEY= 132 | # Clerk sign in URL 133 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 134 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 135 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ 136 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ 137 | ``` 138 | 139 | The [middleware](https://github.com/tinybirdco/ai-analytics-template/blob/main/dashboard/ai-analytics/src/middleware.ts) will get the `org:name` permission from the Clerk user and use it to create a Tinybird JWT token with the `organization` dimension fixed to that value. Read more about Tinybird JWT tokens [here](https://www.tinybird.co/docs/forward/get-started/authentication#json-web-tokens-jwts). 140 | 141 | ## Mock Data 142 | 143 | For local testing, generate mock data with the following commands: 144 | 145 | ```sh 146 | cd tinybird/mock 147 | npm install 148 | npm run generate -- --start-date 2025-02-01 --end-date 2025-03-31 --events-per-day 100 --output ../fixtures/llm_events.ndjson 149 | ``` 150 | 151 | The [generate-llm-events.js](https://github.com/tinybirdco/ai-analytics-template/blob/main/tinybird/mock/generate-llm-events.js) script generates the embeddings. 152 | 153 | ## AI features 154 | 155 | To use the AI features, click on Settings in the dashboard and input an OpenAI API key. 156 | 157 | See the `search` and `extract-cost-parameters` [API routes](https://github.com/tinybirdco/ai-analytics-template/tree/main/dashboard/ai-analytics/src/app/api) for more details on how the AI features work. 158 | 159 | ## Vector search 160 | 161 | The vector search is powered by Tinybird, but embeddings need to be calculated in a separate process. See the [generate-embedding](https://github.com/tinybirdco/ai-analytics-template/blob/main/dashboard/ai-analytics/src/app/api/generate-embedding/route.ts) route for more details. 162 | 163 | The process is: 164 | 165 | - The user inputs a query and clicks the search button. 166 | - The query is sent to the `generate-embedding` route to get the embedding. 167 | - The embedding is sent to the Tinybird `llm_messages` as a query parameter. 168 | - `llm_messages` use `cosineDistance` to find the most similar vectors. 169 | - The frontend shows the table rows with the most similar vectors. 170 | 171 | ## Contributing 172 | 173 | See [CONTRIBUTING.md](./CONTRIBUTING.md) 174 | 175 | ## Support 176 | 177 | Join the [Tinybird Slack community](https://www.tinybird.co/community) to get help with your project. 178 | 179 | ## License 180 | 181 | MIT License 182 | 183 | ©️ Copyright 2025 Tinybird -------------------------------------------------------------------------------- /TEMPLATE.md: -------------------------------------------------------------------------------- 1 | This is a template for an LLM performance tracker dashboard and LLM cost calculator. It is built with Next.js, [Tinybird](https://tinybird.co) and [Clerk](https://clerk.com) 2 | 3 | Use this template to bootstrap a multi-tenant, user-facing LLM analytics dashboard and cost calculator. 4 | 5 | Features: 6 | 7 | - Track LLM costs, requests, tokens and duration by model, provider, organization, project, environment and user 8 | - Multi-tenant user-facing dashboard 9 | - AI cost calculator 10 | - Vector search 11 | - Ask AI integration 12 | 13 | Fork it and make it your own! You can track your own metrics and dimensions. 14 | 15 | ## Set up the project 16 | 17 | Fork the GitHub repository and deploy the data project to Tinybird. 18 | 19 | ```bash 20 | # install the tinybird CLI 21 | curl https://tinybird.co | sh 22 | 23 | # select or create a new workspace 24 | tb login 25 | 26 | # deploy the template 27 | tb --cloud deploy --template https://github.com/tinybirdco/llm-performance-tracker/tree/main/tinybird 28 | ``` 29 | 30 | ## Instrumentation 31 | 32 | Send your data to Tinybird using the [Events API](https://www.tinybird.co/docs/get-data-in/ingest-apis/events-api). Some examples: 33 | 34 | - [LiteLLM (Python)](https://www.tinybird.co/docs/get-data-in/guides/ingest-litellm) 35 | - [Vercel AI SDK (TypeScript)](https://www.tinybird.co/docs/get-data-in/guides/ingest-vercel-ai-sdk) 36 | 37 | ## Use the hosted app 38 | 39 | ```bash 40 | # copy the token to the clipboard 41 | tb --cloud token copy read_pipes && TINYBIRD_TOKEN=$(pbpaste) 42 | 43 | # use the hosted dashboard with your data 44 | open https://llm-tracker.tinybird.live\?token\=$TINYBIRD_TOKEN 45 | ``` 46 | 47 | ## Local development, multi-tenancy, customization and more 48 | 49 | See [README.md](https://github.com/tinybirdco/llm-performance-tracker?tab=readme-ov-file#build-and-deploy-your-own-llm-tracker) 50 | 51 | ## Tech stack 52 | 53 | - [Next.js](https://nextjs.org/) - Application 54 | - [Tinybird](https://tinybird.co) - Analytics 55 | - [OpenAI](https://openai.com/) - AI features 56 | - [Vercel AI SDK](https://sdk.vercel.ai/docs/introduction) - AI features 57 | - [Vercel](https://sdk.vercel.ai/docs/introduction) - Application deployment 58 | - [Clerk](https://clerk.com/) - User management and auth 59 | - [Tremor](https://tremor.so/) - Charts 60 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinybirdco/llm-performance-tracker/910edc8a27a34e2cfe7f19e6b698828fe0eaf910/assets/screenshot.png -------------------------------------------------------------------------------- /dashboard/ai-analytics/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | ## Learn More 20 | 21 | To learn more about Next.js, take a look at the following resources: 22 | 23 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 24 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 25 | 26 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 27 | 28 | ## Deploy on Vercel 29 | 30 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 31 | 32 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 33 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | productionBrowserSourceMaps: true, 5 | webpack: (config) => { 6 | config.devtool = 'source-map'; 7 | return config; 8 | }, 9 | }; 10 | 11 | export default nextConfig; 12 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-analytics", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "NODE_OPTIONS='--inspect' next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ai-sdk/openai": "^1.2.5", 13 | "@auth0/nextjs-auth0": "^4.1.0", 14 | "@clerk/nextjs": "^6.12.5", 15 | "@radix-ui/react-label": "^2.1.2", 16 | "@radix-ui/react-popover": "^1.1.6", 17 | "@radix-ui/react-select": "^2.1.6", 18 | "@radix-ui/react-slot": "^1.1.2", 19 | "@remixicon/react": "^4.6.0", 20 | "@tanstack/react-query": "^5.67.2", 21 | "@tremor/react": "^3.18.7", 22 | "@types/react-syntax-highlighter": "^15.5.13", 23 | "@xenova/transformers": "^2.17.2", 24 | "ai": "^4.1.61", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "date-fns": "^3.6.0", 28 | "jose": "^6.0.10", 29 | "jsonwebtoken": "^9.0.2", 30 | "lucide-react": "^0.482.0", 31 | "next": "15.2.1", 32 | "ogl": "^1.0.11", 33 | "react": "^18.2.0", 34 | "react-day-picker": "^8.10.1", 35 | "react-dom": "^18.2.0", 36 | "react-syntax-highlighter": "^15.6.1", 37 | "server-only": "^0.0.1", 38 | "tailwind-merge": "^3.0.2", 39 | "tailwindcss-animate": "^1.0.7", 40 | "zod": "^3.24.2", 41 | "zustand": "^5.0.3" 42 | }, 43 | "devDependencies": { 44 | "@eslint/eslintrc": "^3", 45 | "@types/jsonwebtoken": "^9.0.9", 46 | "@types/node": "^20", 47 | "@types/react": "^18", 48 | "@types/react-dom": "^18", 49 | "autoprefixer": "^10.0.1", 50 | "eslint": "^9", 51 | "eslint-config-next": "15.2.1", 52 | "postcss": "^8", 53 | "tailwindcss": "^3.3.0", 54 | "typescript": "^5" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/public/onboarding/costcalculator.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinybirdco/llm-performance-tracker/910edc8a27a34e2cfe7f19e6b698828fe0eaf910/dashboard/ai-analytics/public/onboarding/costcalculator.mp4 -------------------------------------------------------------------------------- /dashboard/ai-analytics/public/onboarding/filterchips.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinybirdco/llm-performance-tracker/910edc8a27a34e2cfe7f19e6b698828fe0eaf910/dashboard/ai-analytics/public/onboarding/filterchips.mp4 -------------------------------------------------------------------------------- /dashboard/ai-analytics/public/onboarding/llmcalls.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinybirdco/llm-performance-tracker/910edc8a27a34e2cfe7f19e6b698828fe0eaf910/dashboard/ai-analytics/public/onboarding/llmcalls.mp4 -------------------------------------------------------------------------------- /dashboard/ai-analytics/public/onboarding/vectorsearch.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinybirdco/llm-performance-tracker/910edc8a27a34e2cfe7f19e6b698828fe0eaf910/dashboard/ai-analytics/public/onboarding/vectorsearch.mp4 -------------------------------------------------------------------------------- /dashboard/ai-analytics/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/api/extract-cost-parameters/route.ts: -------------------------------------------------------------------------------- 1 | // src/app/api/extract-cost-parameters/route.ts 2 | import { createOpenAI } from '@ai-sdk/openai'; 3 | import { generateObject } from 'ai'; 4 | import { z } from 'zod'; 5 | import { NextResponse } from 'next/server'; 6 | import { fetchAvailableDimensions } from '@/lib/dimensions'; 7 | import { extractDatesFromQuery } from '@/lib/dateUtils'; 8 | import { generateRandomChatId, hashApiKeyUser, wrapModelWithTinybird } from '@/lib/tinybird-wrapper'; 9 | 10 | // Update the POST function to properly map meta with data 11 | export async function POST(req: Request) { 12 | try { 13 | const { query, apiKey } = await req.json(); 14 | const token = req.headers.get('x-custom-tinybird-token'); 15 | const apiUrl = req.headers.get('x-custom-tinybird-api-url'); 16 | 17 | if (!query) { 18 | return NextResponse.json({ error: 'Query is required' }, { status: 400 }); 19 | } 20 | 21 | if (!apiKey) { 22 | return NextResponse.json({ error: 'OpenAI API key is required' }, { status: 400 }); 23 | } 24 | 25 | // Extract dates using our new function 26 | let { start_date, end_date } = extractDatesFromQuery(query); 27 | 28 | // Fetch pipe definition and available dimensions in parallel 29 | const [pipeDefinition, availableDimensions] = await Promise.all([ 30 | fetchPipeDefinition(token, apiUrl), 31 | fetchAvailableDimensions(token, apiUrl) 32 | ]); 33 | 34 | // Extract dimension values for the system prompt 35 | const dimensionValues: Record = {}; 36 | 37 | // Map meta with data for a more structured representation 38 | if (availableDimensions && availableDimensions.meta && availableDimensions.data && availableDimensions.data.length > 0) { 39 | const metaInfo = availableDimensions.meta; 40 | const dataValues = availableDimensions.data[0]; 41 | 42 | // Create a structured object with meta information and data values 43 | metaInfo.forEach((meta: { name: string, type: string }) => { 44 | dimensionValues[meta.name] = { 45 | type: meta.type, 46 | values: dataValues[meta.name] || [] 47 | }; 48 | }); 49 | } 50 | 51 | console.log('Structured dimension values:', dimensionValues); 52 | 53 | // Update system prompt to include formatted timestamp 54 | const systemPromptText = ` 55 | You are a parameter extractor for an LLM cost calculator. Extract parameters from natural language queries about AI model cost predictions. 56 | 57 | Available dimensions and unique values: 58 | ${availableDimensions?.meta?.map((meta: { name: string, type: string }) => { 59 | const name = meta.name; 60 | const values = dimensionValues[name]?.values || []; 61 | return `- ${name.charAt(0).toUpperCase() + name.slice(1)}: ${JSON.stringify(values)}`; 62 | }).join('\n ') || ''} 63 | 64 | Look for phrases like "filter by", "for", "in", "with", etc. to identify filtering parameters, guess the parameter name based on the available dimensions. Fix typos when necessary. 65 | `; 66 | console.log(systemPromptText); 67 | 68 | const costParametersSchema = z.object({ 69 | promptTokenCost: z.number().nullable().optional(), 70 | completionTokenCost: z.number().nullable().optional(), 71 | discount: z.number().default(0).optional(), 72 | timeframe: z.string().default('last month').optional(), 73 | volumeChange: z.number().default(0).optional(), 74 | start_date: z.string().optional(), 75 | end_date: z.string().optional(), 76 | group_by: z.string().optional(), 77 | model: z.enum((availableDimensions?.data?.[0]?.model || ['gpt-4']) as [string, ...string[]]).optional(), 78 | provider: z.enum((availableDimensions?.data?.[0]?.provider || ['openai']) as [string, ...string[]]).optional(), 79 | environment: z.enum((availableDimensions?.data?.[0]?.environment || ['production']) as [string, ...string[]]).optional(), 80 | organization: z.enum((availableDimensions?.data?.[0]?.organizations || ['']) as [string, ...string[]]).optional(), 81 | project: z.enum((availableDimensions?.data?.[0]?.project || ['']) as [string, ...string[]]).optional(), 82 | user: z.string().optional() 83 | }); 84 | type CostParameters = z.infer; 85 | 86 | const openai = createOpenAI({ apiKey: apiKey }) 87 | const wrappedOpenAI = wrapModelWithTinybird( 88 | openai('gpt-3.5-turbo'), 89 | process.env.NEXT_PUBLIC_TINYBIRD_API_URL!, 90 | process.env.TINYBIRD_JWT_SECRET!, 91 | { 92 | event: 'ai_cost_calculator', 93 | environment: process.env.NODE_ENV, 94 | project: 'llm-tracker', 95 | organization: 'tinybird', 96 | chatId: generateRandomChatId(), 97 | user: hashApiKeyUser(apiKey), 98 | systemPrompt: systemPromptText, 99 | } 100 | ); 101 | 102 | const result = await generateObject({ 103 | model: wrappedOpenAI, 104 | schema: costParametersSchema, 105 | prompt: query, 106 | systemPrompt: systemPromptText, 107 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 108 | } as any); 109 | 110 | // Type assertion to handle the result object 111 | const extractedParams = result.object as CostParameters; 112 | console.log('Extracted parameters:', extractedParams); 113 | 114 | // Ensure timeframe is correctly processed 115 | const timeframe = extractedParams.timeframe || 'last month'; 116 | start_date = extractedParams.start_date || start_date; 117 | end_date = extractedParams.end_date || end_date; 118 | 119 | // Apply defaults for missing parameters 120 | const processedResult = { 121 | model: extractedParams.model || null, 122 | promptTokenCost: extractedParams.promptTokenCost || null, 123 | completionTokenCost: extractedParams.completionTokenCost || null, 124 | discount: extractedParams.discount || 0, 125 | timeframe: timeframe, 126 | volumeChange: extractedParams.volumeChange || 0, 127 | start_date: start_date, 128 | end_date: end_date, 129 | group_by: extractedParams.group_by || null, 130 | organization: extractedParams.organization || null, 131 | project: extractedParams.project || null, 132 | environment: extractedParams.environment || null, 133 | provider: extractedParams.provider || null, 134 | user: extractedParams.user || null, 135 | pipeDefinition: pipeDefinition, 136 | availableDimensions: dimensionValues 137 | }; 138 | 139 | return NextResponse.json(processedResult); 140 | } catch (error) { 141 | console.error('Error processing cost parameters:', error); 142 | return NextResponse.json({ error: 'Failed to process cost parameters' }, { status: 500 }); 143 | } 144 | } 145 | 146 | // Fetch the llm_usage pipe definition 147 | const fetchPipeDefinition = async (token: string | null, apiUrl: string | null) => { 148 | const TINYBIRD_API_URL = apiUrl || 'http://localhost:7181'; 149 | const TINYBIRD_API_KEY = token || process.env.NEXT_PUBLIC_TINYBIRD_API_KEY; 150 | 151 | if (!TINYBIRD_API_KEY) { 152 | console.error('No Tinybird API key available'); 153 | return null; 154 | } 155 | 156 | try { 157 | const url = `${TINYBIRD_API_URL}/v0/pipes/llm_usage`; 158 | console.log('Fetching pipe definition from:', url); 159 | 160 | const response = await fetch(url, { 161 | headers: { 162 | Authorization: `Bearer ${TINYBIRD_API_KEY}`, 163 | }, 164 | }); 165 | 166 | if (!response.ok) { 167 | const error = await response.text(); 168 | console.error('Error fetching pipe definition:', error); 169 | throw new Error('Network response was not ok'); 170 | } 171 | 172 | const data = await response.json(); 173 | console.log('Pipe definition:', data.content); 174 | return data.content; 175 | } catch (error) { 176 | console.error('Error fetching pipe definition:', error); 177 | return null; 178 | } 179 | }; -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/api/generate-embedding/route.ts: -------------------------------------------------------------------------------- 1 | // dashboard/ai-analytics/src/app/api/generate-embedding/route.ts 2 | import { NextResponse } from 'next/server'; 3 | import { pipeline } from '@xenova/transformers'; 4 | import type { FeatureExtractionPipeline } from '@xenova/transformers/types/pipelines'; 5 | 6 | // Cache the model to avoid reloading it for every request 7 | let embeddingPipeline: FeatureExtractionPipeline | null = null; 8 | 9 | async function getEmbeddingModel() { 10 | if (!embeddingPipeline) { 11 | embeddingPipeline = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2') as FeatureExtractionPipeline; 12 | } 13 | return embeddingPipeline; 14 | } 15 | 16 | export async function POST(req: Request) { 17 | try { 18 | const { text } = await req.json(); 19 | 20 | if (!text || typeof text !== 'string') { 21 | return NextResponse.json( 22 | { error: 'Invalid input: text must be a non-empty string' }, 23 | { status: 400 } 24 | ); 25 | } 26 | 27 | // Get the model 28 | const model = await getEmbeddingModel(); 29 | 30 | // Generate embedding 31 | const output = await model(text, { pooling: 'mean', normalize: true }); 32 | const embedding = Array.from(output.data); 33 | 34 | return NextResponse.json({ embedding }); 35 | } catch (error) { 36 | console.error('Error generating embedding:', error); 37 | return NextResponse.json( 38 | { error: 'Failed to generate embedding' }, 39 | { status: 500 } 40 | ); 41 | } 42 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/api/search/route.ts: -------------------------------------------------------------------------------- 1 | import { createOpenAI } from '@ai-sdk/openai'; 2 | import { generateObject } from 'ai'; 3 | import { z } from 'zod'; 4 | import { generateRandomChatId, hashApiKeyUser, wrapModelWithTinybird } from '@/lib/tinybird-wrapper'; 5 | import { fetchAvailableDimensions } from '@/lib/dimensions'; 6 | 7 | 8 | export async function POST(req: Request) { 9 | const { prompt, apiKey } = await req.json(); 10 | const token = req.headers.get('x-custom-tinybird-token'); 11 | const apiUrl = req.headers.get('x-custom-tinybird-api-url'); 12 | 13 | if (!apiKey) { 14 | return Response.json({ error: 'OpenAI API key is required' }, { status: 400 }); 15 | } 16 | 17 | try { 18 | // Fetch available dimensions 19 | const availableDimensions = await fetchAvailableDimensions(token, apiUrl); 20 | console.log(availableDimensions); 21 | 22 | // Create the schema outside the function call 23 | const filterSchema = z.object({ 24 | model: z.enum((availableDimensions?.data?.[0]?.model || ['gpt-4']) as [string, ...string[]]).optional(), 25 | provider: z.enum((availableDimensions?.data?.[0]?.provider || ['openai']) as [string, ...string[]]).optional(), 26 | environment: z.enum((availableDimensions?.data?.[0]?.environment || ['production']) as [string, ...string[]]).optional(), 27 | organization: z.enum((availableDimensions?.data?.[0]?.organizations || ['']) as [string, ...string[]]).optional(), 28 | project: z.enum((availableDimensions?.data?.[0]?.project || ['']) as [string, ...string[]]).optional(), 29 | date_range: z.enum(['last month', 'last week'] as [string, ...string[]]).optional(), 30 | }); 31 | 32 | const systemPromptText = `You are a filter parser for an analytics dashboard. Convert natural language into filter key-value pairs. 33 | Available dimensions: ${Object.keys(availableDimensions?.data?.[0] || {}).join(', ')}. 34 | Common values: ${JSON.stringify(availableDimensions?.data?.[0] || {}, null, 2)}. 35 | Return only valid values from the provided dimensions, fix typos when necessary.`; 36 | console.log(systemPromptText); 37 | 38 | const openai = createOpenAI({ apiKey: apiKey }); 39 | const wrappedOpenAI = wrapModelWithTinybird( 40 | openai('gpt-3.5-turbo'), 41 | process.env.NEXT_PUBLIC_TINYBIRD_API_URL!, 42 | process.env.TINYBIRD_JWT_SECRET!, 43 | { 44 | event: 'search_filter', 45 | environment: process.env.NODE_ENV, 46 | project: 'llm-tracker', 47 | organization: 'tinybird', 48 | chatId: generateRandomChatId(), 49 | user: hashApiKeyUser(apiKey), 50 | systemPrompt: systemPromptText, 51 | } 52 | ); 53 | 54 | const result = await generateObject({ 55 | model: wrappedOpenAI, 56 | schema: filterSchema, 57 | prompt, 58 | systemPrompt: systemPromptText, 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | } as any); 61 | 62 | return Response.json(result.object); 63 | } catch (error) { 64 | console.error('Error processing search:', error); 65 | 66 | // Check if it's an API key error 67 | if (error instanceof Error && error.message.includes('API key')) { 68 | return Response.json({ error: 'Invalid OpenAI API key' }, { status: 401 }); 69 | } 70 | 71 | return Response.json({ error: 'Failed to process search query' }, { status: 500 }); 72 | } 73 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/ApiKeyInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import { useApiKeyStore } from '@/stores/apiKeyStore'; 5 | import { X, ArrowRight, Eye, EyeOff } from 'lucide-react'; 6 | 7 | interface ApiKeyInputProps { 8 | isOpen: boolean; 9 | onClose: () => void; 10 | } 11 | 12 | export default function ApiKeyInput({ isOpen, onClose }: ApiKeyInputProps) { 13 | const { openaiKey, setOpenaiKey, clearOpenaiKey } = useApiKeyStore(); 14 | const [inputKey, setInputKey] = useState(''); 15 | const [isVisible, setIsVisible] = useState(false); 16 | 17 | if (!isOpen) return null; 18 | 19 | const handleSave = () => { 20 | if (inputKey.trim()) { 21 | setOpenaiKey(inputKey.trim()); 22 | setInputKey(''); 23 | setIsVisible(false); 24 | } 25 | }; 26 | 27 | return ( 28 |
29 |
30 | 31 |
32 | {/* Header */} 33 |
34 |

Settings

35 | 38 |
39 | 40 | {/* Content */} 41 |
42 | {openaiKey ? ( 43 |
44 |
45 | 51 |
52 | 62 |
63 |
64 |
65 | 71 |
72 |
73 | ) : ( 74 |
75 |
76 | setInputKey(e.target.value)} 80 | onKeyDown={(e) => { 81 | if (e.key === 'Enter' && inputKey.trim()) { 82 | e.preventDefault(); 83 | handleSave(); 84 | } 85 | }} 86 | placeholder="Introduce your OpenAI API Key" 87 | className="w-full h-[48px] px-4 pr-12 py-2 bg-tremor-background-subtle dark:bg-dark-tremor-background-subtle focus:outline-none focus:ring-1 focus:ring-white placeholder:text-[#8D8D8D] text-[#F4F4F4] placeholder:text-sm font-['Roboto']" 88 | /> 89 |
90 | 97 |
98 |
99 |

100 | Your API key is stored locally in your browser and never sent to our servers. 101 |

102 |
103 | )} 104 | 105 | {/* Save Button */} 106 | {!openaiKey && ( 107 |
108 | 119 |
120 | )} 121 |
122 |
123 |
124 | ); 125 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/CustomBarList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Card } from '@tremor/react'; 4 | import { useState, useMemo, useEffect } from 'react'; 5 | import { RiSearchLine } from '@remixicon/react'; 6 | import { Dialog, DialogPanel } from '@tremor/react'; 7 | import { X, Check } from 'lucide-react'; 8 | 9 | interface BarListItem { 10 | name: string; 11 | value: number; 12 | icon?: React.ReactNode; 13 | } 14 | 15 | interface CustomBarListProps { 16 | data: BarListItem[]; 17 | valueFormatter?: (value: number) => string; 18 | onSelectionChange?: (selectedItems: string[]) => void; 19 | initialSelectedItems?: string[]; 20 | } 21 | 22 | const defaultFormatter = (number: number) => 23 | `${Intl.NumberFormat('us').format(number).toString()}`; 24 | 25 | export default function CustomBarList({ 26 | data, 27 | valueFormatter = defaultFormatter, 28 | onSelectionChange, 29 | initialSelectedItems = [] 30 | }: CustomBarListProps) { 31 | const [isOpen, setIsOpen] = useState(false); 32 | const [searchQuery, setSearchQuery] = useState(''); 33 | const [selectedItems, setSelectedItems] = useState(initialSelectedItems); 34 | 35 | // Update selected items when initialSelectedItems changes 36 | useEffect(() => { 37 | setSelectedItems(initialSelectedItems); 38 | }, [initialSelectedItems]); 39 | 40 | // Memoize filtered items to prevent unnecessary recalculations 41 | const filteredItems = useMemo(() => { 42 | if (!searchQuery.trim()) return data; 43 | 44 | const query = searchQuery.toLowerCase().trim(); 45 | return data.filter((item) => 46 | item.name.toLowerCase().includes(query) 47 | ); 48 | }, [data, searchQuery]); 49 | 50 | // Calculate total value for header 51 | const totalValue = useMemo(() => 52 | data.reduce((sum, item) => sum + item.value, 0), 53 | [data] 54 | ); 55 | const hasMoreItems = data.length > 5; 56 | 57 | const handleBarClick = (itemName: string) => { 58 | setSelectedItems(prev => { 59 | const newSelection = prev.includes(itemName) 60 | ? prev.filter(name => name !== itemName) 61 | : [...prev, itemName]; 62 | 63 | onSelectionChange?.(newSelection); 64 | return newSelection; 65 | }); 66 | }; 67 | 68 | const handleClearSelection = () => { 69 | setSelectedItems([]); 70 | onSelectionChange?.([]); 71 | }; 72 | 73 | // Custom bar rendering with icons and improved styling 74 | const renderCustomBarList = (items: BarListItem[]) => ( 75 |
76 | {items.map((item) => { 77 | // Calculate percentage for bar width (max 92% to leave room for text) 78 | const maxValue = Math.max(...items.map(i => i.value)); 79 | const percentage = maxValue > 0 ? (item.value / maxValue) * 92 : 0; 80 | const isSelected = selectedItems.includes(item.name); 81 | 82 | return ( 83 |
handleBarClick(item.name)} 87 | > 88 |
89 |
90 | {item.icon && ( 91 |
92 | {item.icon} 93 |
94 | )} 95 |

96 | {item.name} 97 |

98 | {isSelected && ( 99 | 100 | )} 101 |
102 |

103 | {valueFormatter(item.value)} 104 |

105 |
106 |
107 |
111 |
112 |
113 | ); 114 | })} 115 |
116 | ); 117 | 118 | return ( 119 | <> 120 | 124 |
125 |
126 |

Cost Breakdown

127 | {selectedItems.length > 0 && ( 128 |
129 | {selectedItems.length} selected 130 | 139 |
140 | )} 141 |
142 |

143 | {valueFormatter(totalValue)} 144 |

145 |
146 | 147 | {renderCustomBarList(data.slice(0, 5))} 148 | 149 | {hasMoreItems && ( 150 |
151 | 168 |
169 | )} 170 | 171 |
172 | )} 173 | 174 | { 177 | setIsOpen(false); 178 | setSearchQuery(''); // Clear search when closing 179 | }} 180 | static={true} 181 | className="z-[100]" 182 | > 183 |
184 | 185 | {/* Header */} 186 |
187 |
188 |

All Items

189 | {selectedItems.length > 0 && ( 190 |
191 | {selectedItems.length} selected 192 | 201 |
202 | )} 203 |
204 | 213 |
214 | 215 | {/* Content */} 216 |
217 |
218 | setSearchQuery(e.target.value)} 222 | onKeyDown={(e) => { 223 | if (e.key === 'Enter') { 224 | e.preventDefault(); 225 | // The search is already handled by the filteredItems logic 226 | // No need for additional action 227 | } 228 | }} 229 | placeholder="Search items..." 230 | className="w-full h-[48px] px-4 pr-12 py-2 bg-[#353535] focus:outline-none focus:ring-1 focus:ring-white placeholder:text-[#8D8D8D] text-[#F4F4F4] placeholder:text-sm font-['Roboto']" 231 | /> 232 | 241 |
242 | 243 |
244 | {filteredItems.length > 0 ? ( 245 | renderCustomBarList(filteredItems) 246 | ) : ( 247 |
248 | 249 | 250 | 251 |

No results found for "{searchQuery}"

252 | 258 |
259 | )} 260 |
261 |
262 |
263 |
264 | 265 | 266 | ); 267 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/CustomTooltip.tsx: -------------------------------------------------------------------------------- 1 | interface TooltipEntry { 2 | name: string; 3 | value: number | string; 4 | color: string; 5 | } 6 | 7 | interface CustomTooltipProps { 8 | date?: string; 9 | entries: TooltipEntry[]; 10 | unit?: string; 11 | } 12 | 13 | export default function CustomTooltip({ date, entries, unit = '' }: CustomTooltipProps) { 14 | return ( 15 |
22 | {date && ( 23 |
24 | {date} 25 |
26 | )} 27 | {entries.map((entry, index) => ( 28 |
29 |
30 |
34 | {entry.name} 35 |
36 | 37 | {unit == '$' ? `${unit}` : ''}{typeof entry.value === 'number' ? entry.value.toLocaleString() : entry.value}{unit == '$' ? '' : ` ${unit}`} 38 | 39 |
40 | ))} 41 |
42 | ); 43 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/FilterChips.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { X } from 'lucide-react'; 4 | 5 | interface FilterChip { 6 | dimension: string; 7 | value: string; 8 | onRemove: (value: string) => void; 9 | } 10 | 11 | export default function FilterChips({ dimension, value, onRemove }: FilterChip) { 12 | return ( 13 |
14 | {dimension}: {value} 15 | 21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/MetricsCards.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import SparkChartContainer from '../containers/SparkChartContainer'; 4 | 5 | interface MetricsCardsProps { 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | data: any; 8 | isLoading: boolean; 9 | } 10 | 11 | export default function MetricsCards({ data, isLoading }: MetricsCardsProps) { 12 | return ( 13 |
14 | 23 | 31 | 39 |
40 | ); 41 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/ResizableSplitView.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react'; 2 | 3 | interface ResizableSplitViewProps { 4 | topComponent: React.ReactNode; 5 | bottomComponent: React.ReactNode; 6 | initialTopHeight?: string; 7 | minTopHeight?: string; 8 | minBottomHeight?: string; 9 | } 10 | 11 | export default function ResizableSplitView({ 12 | topComponent, 13 | bottomComponent, 14 | initialTopHeight = '60vh', 15 | minTopHeight = '30vh', 16 | minBottomHeight = '20vh' 17 | }: ResizableSplitViewProps) { 18 | const [topHeight, setTopHeight] = useState(initialTopHeight); 19 | const [isDragging, setIsDragging] = useState(false); 20 | const containerRef = useRef(null); 21 | const dragHandleRef = useRef(null); 22 | 23 | // Convert vh to pixels 24 | const vhToPixels = (vh: string) => { 25 | const vhValue = parseFloat(vh); 26 | return (window.innerHeight * vhValue) / 100; 27 | }; 28 | 29 | // Convert pixels to vh 30 | const pixelsToVh = (pixels: number) => { 31 | return `${(pixels / window.innerHeight) * 100}vh`; 32 | }; 33 | 34 | // Handle mouse down on the drag handle 35 | const handleMouseDown = (e: React.MouseEvent) => { 36 | setIsDragging(true); 37 | e.preventDefault(); 38 | }; 39 | 40 | // Handle mouse move during dragging 41 | const handleMouseMove = (e: MouseEvent) => { 42 | if (!isDragging || !containerRef.current) return; 43 | 44 | const containerRect = containerRef.current.getBoundingClientRect(); 45 | const containerHeight = containerRect.height; 46 | const mouseY = e.clientY - containerRect.top; 47 | 48 | // Calculate new height in pixels 49 | let newHeightPixels = mouseY; 50 | 51 | // Apply min/max constraints 52 | const minTopPixels = vhToPixels(minTopHeight); 53 | const minBottomPixels = vhToPixels(minBottomHeight); 54 | 55 | if (newHeightPixels < minTopPixels) { 56 | newHeightPixels = minTopPixels; 57 | } else if (containerHeight - newHeightPixels < minBottomPixels) { 58 | newHeightPixels = containerHeight - minBottomPixels; 59 | } 60 | 61 | // Convert back to vh and update state 62 | setTopHeight(pixelsToVh(newHeightPixels)); 63 | }; 64 | 65 | // Handle mouse up to stop dragging 66 | const handleMouseUp = () => { 67 | setIsDragging(false); 68 | }; 69 | 70 | // Add and remove event listeners 71 | useEffect(() => { 72 | if (isDragging) { 73 | window.addEventListener('mousemove', handleMouseMove); 74 | window.addEventListener('mouseup', handleMouseUp); 75 | } 76 | 77 | return () => { 78 | window.removeEventListener('mousemove', handleMouseMove); 79 | window.removeEventListener('mouseup', handleMouseUp); 80 | }; 81 | }, [isDragging]); 82 | 83 | return ( 84 |
88 | {/* Top component */} 89 |
90 | {topComponent} 91 |
92 | 93 | {/* Drag handle */} 94 |
101 |
102 |
103 | 104 | {/* Bottom component */} 105 |
106 | {bottomComponent} 107 |
108 |
109 | ); 110 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/RootLayoutContent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ReactNode, useEffect } from 'react'; 4 | import { useTinybirdToken } from '@/providers/TinybirdProvider'; 5 | import { useModal } from '../context/ModalContext'; 6 | import { useKeyboardShortcut } from '@/hooks/useKeyboardShortcut'; 7 | import CostPredictionModal from './CostPredictionModal'; 8 | import { FloatingNotification } from '@/components/ui/floating-notification'; 9 | import { useSearchParams } from 'next/navigation'; 10 | import { getApiUrlFromHost, extractHostFromToken } from '@/lib/tinybird-utils'; 11 | 12 | interface RootLayoutContentProps { 13 | children: ReactNode; 14 | initialToken: string; 15 | initialOrgName: string; 16 | } 17 | 18 | export function RootLayoutContent({ children, initialToken, initialOrgName }: RootLayoutContentProps) { 19 | const { setToken, setOrgName, setApiUrl } = useTinybirdToken(); 20 | const searchParams = useSearchParams(); 21 | const tokenParam = searchParams.get('token'); 22 | 23 | // Set the initial values from the server 24 | useEffect(() => { 25 | if (tokenParam) { 26 | setToken(tokenParam); 27 | // Extract host from token if it's a JWT 28 | try { 29 | const host = extractHostFromToken(tokenParam); 30 | if (host) { 31 | // Convert host to API URL 32 | const apiUrl = getApiUrlFromHost(host); 33 | setApiUrl(apiUrl); 34 | setOrgName(host); 35 | } 36 | } catch (e) { 37 | console.error('Error decoding token:', e); 38 | } 39 | } else { 40 | setToken(initialToken); 41 | setOrgName(initialOrgName); 42 | setApiUrl(process.env.NEXT_PUBLIC_TINYBIRD_API_URL || 'https://api.tinybird.co'); 43 | } 44 | }, [tokenParam, initialToken, initialOrgName, setToken, setOrgName, setApiUrl]); 45 | 46 | return ( 47 | <> 48 | {children} 49 | 50 | 57 | 58 | ); 59 | } 60 | 61 | function ModalController({ filters }: { filters: Record }) { 62 | const { isCostPredictionOpen, openCostPrediction, closeCostPrediction } = useModal(); 63 | 64 | useKeyboardShortcut('k', () => { 65 | if (!isCostPredictionOpen) { 66 | openCostPrediction(); 67 | } 68 | }, true); 69 | 70 | return ( 71 | 76 | ); 77 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/SparkChart.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | Card, 5 | SparkAreaChart, 6 | LineChart, 7 | BarChart, 8 | AreaChart, 9 | } from '@tremor/react'; 10 | import CustomTooltip from './CustomTooltip'; 11 | import { useState } from 'react'; 12 | 13 | // Default colors for all categories 14 | const defaultColors = [ 15 | '#27F795', // 100% opacity 16 | '#27F795CC', // 80% opacity 17 | '#27F79599', // 60% opacity 18 | '#27F79566', // 40% opacity 19 | '#27F79533' // 20% opacity 20 | ]; 21 | 22 | type ChartType = 'area' | 'line' | 'bar' | 'stacked-bar' | 'stacked-area'; 23 | 24 | interface SparkChartProps { 25 | data: Array<{ 26 | date: string; 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | [key: string]: any; 29 | }>; 30 | categories: string[]; 31 | chartType?: ChartType; 32 | title: string; 33 | value: string; 34 | className?: string; 35 | unit?: string; 36 | isLoading?: boolean; 37 | } 38 | 39 | export default function SparkChart({ 40 | data, 41 | categories, 42 | chartType = 'line', 43 | title, 44 | value, 45 | className, 46 | unit = '', 47 | isLoading = false 48 | }: SparkChartProps) { 49 | const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); 50 | 51 | const handleMouseMove = (e: React.MouseEvent) => { 52 | setMousePosition({ 53 | x: e.clientX + 10, // Add 10px offset to prevent tooltip from covering cursor 54 | y: e.clientY - 10 55 | }); 56 | }; 57 | 58 | const ChartComponent = { 59 | 'stacked-bar': BarChart, 60 | 'stacked-area': AreaChart, 61 | 'area': SparkAreaChart, 62 | 'line': LineChart, 63 | 'bar': BarChart 64 | }[chartType]; 65 | 66 | const isStacked = chartType.startsWith('stacked-'); 67 | 68 | // Assign colors based on index in the categories array 69 | const colors = categories.map((_, index) => 70 | defaultColors[index % defaultColors.length] 71 | ); 72 | 73 | return ( 74 | 75 | {isLoading ? ( 76 |
77 |
78 |
79 | ) : !data?.length ? ( 80 |
81 |

No data available

82 |
83 | ) : ( 84 | <> 85 |

86 | {title} 87 |

88 |

89 | {value} 90 |

91 |
92 | ( 108 |
115 | ({ 119 | name: String(entry.name), 120 | value: Array.isArray(entry.value) ? entry.value[0] || 0 : entry.value || 0, 121 | color: entry.color || '#27F795' 122 | })) || []} 123 | /> 124 |
125 | )} 126 | /> 127 |
128 | 129 | )} 130 |
131 | ); 132 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/TabbedPane.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Tab, TabGroup, TabList } from '@tremor/react'; 4 | import { useGenericCounter } from '@/hooks/useTinybirdData'; 5 | import { useSearchParams } from 'next/navigation'; 6 | import CustomBarList from './CustomBarList'; 7 | import { useState, useEffect } from 'react'; 8 | import { tabs } from '../constants'; 9 | import { useTinybirdToken } from '@/providers/TinybirdProvider'; 10 | import { 11 | Server, 12 | Cloud, 13 | User, 14 | Building2, 15 | Cpu 16 | } from 'lucide-react'; 17 | 18 | // CSS for hiding scrollbar 19 | const noScrollbarStyle = ` 20 | .no-scrollbar::-webkit-scrollbar { 21 | display: none; 22 | } 23 | .no-scrollbar { 24 | -ms-overflow-style: none; 25 | scrollbar-width: none; 26 | } 27 | `; 28 | 29 | // Add style to document 30 | if (typeof document !== 'undefined') { 31 | const style = document.createElement('style'); 32 | style.textContent = noScrollbarStyle; 33 | document.head.appendChild(style); 34 | } 35 | 36 | // Custom OpenAI Icon 37 | const OpenAIIcon = () => ( 38 | 39 | 40 | 41 | ); 42 | 43 | // Custom Anthropic Icon 44 | const AnthropicIcon = () => ( 45 | 46 | 47 | 48 | ); 49 | 50 | // Custom Google AI Icon 51 | const GoogleAIIcon = () => ( 52 | 53 | 54 | 55 | ); 56 | 57 | // Helper function to get icon for provider 58 | const getProviderIcon = (provider: string) => { 59 | const lowerProvider = provider.toLowerCase(); 60 | if (lowerProvider.includes('openai')) { 61 | return ; 62 | } else if (lowerProvider.includes('anthropic')) { 63 | return ; 64 | } else if (lowerProvider.includes('google')) { 65 | return ; 66 | } else { 67 | return ; 68 | } 69 | }; 70 | 71 | // Helper function to get icon for model 72 | const getModelIcon = (model: string) => { 73 | const lowerModel = model.toLowerCase(); 74 | if (lowerModel.includes('gpt')) { 75 | return ; 76 | } else if (lowerModel.includes('claude')) { 77 | return ; 78 | } else if (lowerModel.includes('palm') || lowerModel.includes('gemini')) { 79 | return ; 80 | } else { 81 | return ; 82 | } 83 | }; 84 | 85 | // Helper function to get icon for environment 86 | const getEnvironmentIcon = (env: string) => { 87 | const lowerEnv = env.toLowerCase(); 88 | if (lowerEnv.includes('prod')) { 89 | return ; 90 | } else if (lowerEnv.includes('staging')) { 91 | return ; 92 | } else if (lowerEnv.includes('dev')) { 93 | return ; 94 | } else { 95 | return ; 96 | } 97 | }; 98 | 99 | interface TabbedPaneProps { 100 | filters: Record; 101 | onFilterUpdate: (dimension: string, dimensionName: string, values: string[]) => void; 102 | } 103 | 104 | export default function TabbedPane({ filters, onFilterUpdate }: TabbedPaneProps) { 105 | const { orgName } = useTinybirdToken(); 106 | const searchParams = useSearchParams(); 107 | const filteredTabs = tabs.filter(tab => !orgName || tab.key !== 'organization'); 108 | const initialDimension = searchParams.get('dimension') || filteredTabs[0].key; 109 | const [selectedTab, setSelectedTab] = useState(initialDimension); 110 | const [barListData, setBarListData] = useState>([]); 111 | const [selectedValues, setSelectedValues] = useState([]); 112 | 113 | // Create a copy of filters without the current dimension to avoid filtering by it 114 | const queryFilters = { ...filters }; 115 | delete queryFilters[selectedTab]; 116 | 117 | // Pass all filters to the query, but exclude the current dimension 118 | const { data, isLoading, error } = useGenericCounter({ 119 | dimension: selectedTab, 120 | ...queryFilters 121 | }); 122 | 123 | // Add effect to sync with URL params 124 | useEffect(() => { 125 | const params = new URLSearchParams(window.location.search); 126 | const paramValue = params.get(selectedTab); 127 | if (paramValue) { 128 | setSelectedValues(paramValue.split(',')); 129 | } else { 130 | setSelectedValues([]); 131 | } 132 | }, [selectedTab, searchParams]); 133 | 134 | const handleSelectionChange = (newSelection: string[]) => { 135 | setSelectedValues(newSelection); 136 | 137 | // Update URL without scroll 138 | const params = new URLSearchParams(searchParams.toString()); 139 | if (newSelection.length > 0) { 140 | params.set(selectedTab, newSelection.join(',')); 141 | } else { 142 | params.delete(selectedTab); 143 | if (filters[selectedTab]) { 144 | delete filters[selectedTab]; 145 | } 146 | } 147 | window.history.replaceState({}, '', `?${params.toString()}`); 148 | 149 | // Notify parent to update filters 150 | onFilterUpdate(selectedTab, filteredTabs.find(t => t.key === selectedTab)?.name || selectedTab, newSelection); 151 | }; 152 | 153 | const handleTabChange = (index: number) => { 154 | const tab = filteredTabs[index]; 155 | const dimension = tab.key; 156 | 157 | // Update URL without scroll 158 | const params = new URLSearchParams(searchParams.toString()); 159 | params.set('dimension', dimension); 160 | window.history.replaceState({}, '', `?${params.toString()}`); 161 | 162 | setSelectedTab(dimension); 163 | }; 164 | 165 | // Update barListData when data changes 166 | useEffect(() => { 167 | if (data?.data) { 168 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 169 | const newData = data.data.map((item: any) => { 170 | const name = item.category || 'Unknown'; 171 | let icon; 172 | 173 | // Assign icon based on the selected tab 174 | switch (selectedTab) { 175 | case 'provider': 176 | icon = getProviderIcon(name); 177 | break; 178 | case 'model': 179 | icon = getModelIcon(name); 180 | break; 181 | case 'environment': 182 | icon = getEnvironmentIcon(name); 183 | break; 184 | case 'organization': 185 | icon = ; 186 | break; 187 | case 'user': 188 | icon = ; 189 | break; 190 | default: 191 | icon = ; 192 | } 193 | 194 | return { 195 | name, 196 | value: item.total_cost || 0, 197 | icon 198 | }; 199 | }); 200 | setBarListData(newData); 201 | } 202 | }, [data, selectedTab]); 203 | 204 | return ( 205 |
206 | t.key === selectedTab)} 208 | onIndexChange={handleTabChange} 209 | > 210 |
{ 213 | e.currentTarget.scrollLeft += e.deltaY; 214 | e.preventDefault(); 215 | }} 216 | > 217 | 218 | {filteredTabs.map((tab) => ( 219 | 223 | `px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap 224 | ${selected 225 | ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300' 226 | : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}` 227 | } 228 | > 229 | {tab.name} 230 | 231 | ))} 232 | 233 |
234 |
235 | {isLoading ? ( 236 |
Loading...
237 | ) : error ? ( 238 |
Error loading data
239 | ) : ( 240 | `$${value.toLocaleString()}`} 243 | onSelectionChange={handleSelectionChange} 244 | initialSelectedItems={selectedValues} 245 | /> 246 | )} 247 |
248 |
249 |
250 | ); 251 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/TopBar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSearchParams, useRouter } from 'next/navigation'; 4 | import { UserButton, SignedIn, SignedOut } from "@clerk/nextjs"; 5 | import FilterChips from './FilterChips'; 6 | import UserFilterChip from './UserFilterChip'; 7 | import { useRef, useState, useEffect } from 'react'; 8 | import DateRangeSelector from './DateRangeSelector'; 9 | import { SettingsIcon, SignInIcon } from './icons'; 10 | import { useModal } from '../context/ModalContext'; 11 | import { useApiKeyStore } from '@/stores/apiKeyStore'; 12 | import ApiKeyInput from './ApiKeyInput'; 13 | import { Dialog, DialogPanel } from '@tremor/react'; 14 | import { Sparkles } from 'lucide-react'; 15 | import SignInModal from './SignInModal'; 16 | import { generateUserHash } from '@/lib/user-hash'; 17 | import { useTinybirdToken } from '@/providers/TinybirdProvider'; 18 | 19 | interface Selection { 20 | dimension: string; 21 | dimensionName: string; 22 | values: string[]; 23 | } 24 | 25 | interface TopBarProps { 26 | selections: Selection[]; 27 | onRemoveFilter: (dimension: string, value: string) => void; 28 | } 29 | 30 | export default function TopBar({ selections, onRemoveFilter }: TopBarProps) { 31 | const router = useRouter(); 32 | const searchParams = useSearchParams(); 33 | const inputRef = useRef(null); 34 | const [isLoading, setIsLoading] = useState(false); 35 | const { openCostPrediction } = useModal(); 36 | const { openaiKey } = useApiKeyStore(); 37 | const [isSettingsOpen, setIsSettingsOpen] = useState(false); 38 | const [isSignInOpen, setIsSignInOpen] = useState(false); 39 | const [userHash, setUserHash] = useState(''); 40 | const { token, apiUrl } = useTinybirdToken(); 41 | 42 | // Generate user hash when API key changes 43 | useEffect(() => { 44 | if (openaiKey) { 45 | const hash = generateUserHash(openaiKey); 46 | setUserHash(hash); 47 | } else { 48 | setUserHash(''); 49 | } 50 | }, [openaiKey]); 51 | 52 | const handleUserFilterClick = () => { 53 | if (!openaiKey) { 54 | setIsSettingsOpen(true); 55 | return; 56 | } 57 | }; 58 | 59 | const handleSearch = async (e: React.KeyboardEvent) => { 60 | if (e.key === 'Enter') { 61 | const input = e.currentTarget.value; 62 | if (input.trim()) { 63 | // Check if API key is available 64 | if (!openaiKey) { 65 | alert('Please provide your OpenAI API key in settings to use this feature.'); 66 | return; 67 | } 68 | 69 | setIsLoading(true); 70 | console.log('Searching for:', input); 71 | 72 | try { 73 | const response = await fetch('/api/search', { 74 | method: 'POST', 75 | headers: { 76 | 'Content-Type': 'application/json', 77 | 'x-custom-tinybird-token': token || '', 78 | 'x-custom-tinybird-api-url': apiUrl || '', 79 | }, 80 | body: JSON.stringify({ prompt: input, apiKey: openaiKey }), 81 | }); 82 | 83 | if (!response.ok) { 84 | const errorData = await response.json().catch(() => ({})); 85 | throw new Error(errorData.message || `API error: ${response.status}`); 86 | } 87 | 88 | const filters = await response.json(); 89 | console.log('AI response:', filters); 90 | 91 | // Apply filters to URL 92 | const params = new URLSearchParams(searchParams.toString()); 93 | 94 | // Process each filter from the AI response 95 | Object.entries(filters).forEach(([key, value]) => { 96 | if (!value) return; // Skip empty values 97 | 98 | // For date_range, handle special case 99 | if (key === 'date_range') { 100 | // TODO: Handle date range logic here if needed 101 | return; 102 | } 103 | 104 | // For regular dimensions, add to URL params 105 | params.set(key, value as string); 106 | }); 107 | 108 | // Update the URL with new filters 109 | const newUrl = `?${params.toString()}`; 110 | console.log('Updating URL to:', newUrl); 111 | router.push(newUrl); 112 | 113 | // Clear input 114 | if (inputRef.current) { 115 | inputRef.current.value = ''; 116 | } 117 | } catch (error) { 118 | console.error('Search error:', error); 119 | alert('Failed to process your search. Please try again.'); 120 | } finally { 121 | setIsLoading(false); 122 | } 123 | } 124 | } 125 | }; 126 | 127 | const handleRemoveFilter = (dimension: string, value: string) => { 128 | // Get current params 129 | const params = new URLSearchParams(searchParams.toString()); 130 | const currentValues = params.get(dimension)?.split(',') || []; 131 | 132 | // Remove the value 133 | const newValues = currentValues.filter(v => v !== value); 134 | 135 | // Update or remove the param 136 | if (newValues.length > 0) { 137 | params.set(dimension, newValues.join(',')); 138 | } else { 139 | params.delete(dimension); 140 | } 141 | 142 | // Update URL 143 | router.push(`?${params.toString()}`); 144 | 145 | // Notify parent 146 | onRemoveFilter(dimension, value); 147 | }; 148 | 149 | // Helper function to get dimension display name 150 | // const getDimensionName = (dimension: string): string => { 151 | // const tab = tabs.find(t => t.key === dimension); 152 | // return tab ? tab.name : dimension.charAt(0).toUpperCase() + dimension.slice(1); 153 | // }; 154 | 155 | return ( 156 |
157 |
158 |
159 | 169 |
170 | 178 |
179 | {isLoading ? ( 180 |
181 | ) : ( 182 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 183 | 186 | )} 187 |
188 |
189 | 190 |
191 | 192 |
193 | 200 | 201 | 202 | 208 | 209 | 210 | 211 | 212 |
213 |
214 | 215 |
216 |
217 | 218 |
219 | 220 | {selections.map((selection) => ( 221 | selection.values.map((value) => ( 222 | handleRemoveFilter(selection.dimension, value)} 227 | /> 228 | )) 229 | ))} 230 |
231 | 232 | {/* Settings Modal */} 233 | setIsSettingsOpen(false)} 236 | static={true} 237 | > 238 |
239 | 240 |
241 |
242 |

Settings

243 | 252 |
253 | 254 | setIsSettingsOpen(false)} /> 255 |
256 |
257 |
258 | 259 | {/* Sign In Modal */} 260 | setIsSignInOpen(false)} 263 | static={true} 264 | > 265 | setIsSignInOpen(false)} 268 | /> 269 | 270 |
271 | ); 272 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/components/UserFilterChip.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import { useSearchParams, useRouter } from 'next/navigation'; 5 | import { useApiKeyStore } from '@/stores/apiKeyStore'; 6 | 7 | interface UserFilterChipProps { 8 | userHash: string; 9 | } 10 | 11 | export default function UserFilterChip({ userHash }: UserFilterChipProps) { 12 | const [isActive, setIsActive] = useState(false); 13 | const searchParams = useSearchParams(); 14 | const router = useRouter(); 15 | const { openaiKey } = useApiKeyStore(); 16 | 17 | // Check if user filter is active based on URL parameters 18 | useEffect(() => { 19 | const userFilter = searchParams.get('user'); 20 | setIsActive(userFilter === userHash); 21 | }, [searchParams, userHash]); 22 | 23 | const handleToggle = () => { 24 | if (!openaiKey) return; 25 | 26 | const newParams = new URLSearchParams(searchParams.toString()); 27 | 28 | if (isActive) { 29 | // Remove user filter 30 | newParams.delete('user'); 31 | } else { 32 | // Clear all other filters and set user filter 33 | newParams.forEach((_, key) => newParams.delete(key)); 34 | newParams.set('user', userHash); 35 | } 36 | 37 | router.push(`?${newParams.toString()}`); 38 | }; 39 | 40 | return ( 41 |
50 |
55 | {isActive &&
} 56 |
57 | {isActive ? 'All LLM calls' : 'Your LLM calls'} 58 |
59 | ); 60 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/constants.ts: -------------------------------------------------------------------------------- 1 | export const tabs = [ 2 | { name: 'Model', key: 'model' }, 3 | { name: 'Provider', key: 'provider' }, 4 | { name: 'Organization', key: 'organization' }, 5 | { name: 'Project', key: 'project' }, 6 | { name: 'Environment', key: 'environment' }, 7 | { name: 'User', key: 'user' } 8 | ] as const; 9 | 10 | export type TabKey = typeof tabs[number]['key']; -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/containers/DataTableContainer.tsx: -------------------------------------------------------------------------------- 1 | // dashboard/ai-analytics/src/app/containers/DataTableContainer.tsx 2 | 'use client'; 3 | 4 | import { useState, useEffect } from 'react'; 5 | import DataTable from '../components/DataTable'; 6 | import { useLLMMessages } from '@/hooks/useTinybirdData'; 7 | import { Search, Sparkles } from 'lucide-react'; 8 | 9 | interface DataTableContainerProps { 10 | isLoading: boolean; 11 | filters: Record; 12 | } 13 | 14 | export default function DataTableContainer({ isLoading, filters }: DataTableContainerProps) { 15 | const [searchText, setSearchText] = useState(null); 16 | const [searchInput, setSearchInput] = useState(''); 17 | const [embedding, setEmbedding] = useState(null); 18 | const [isGeneratingEmbedding, setIsGeneratingEmbedding] = useState(false); 19 | 20 | // Generate embedding when search text changes 21 | useEffect(() => { 22 | async function generateEmbedding() { 23 | if (!searchText) { 24 | setEmbedding(null); 25 | return; 26 | } 27 | 28 | setIsGeneratingEmbedding(true); 29 | try { 30 | const response = await fetch('/api/generate-embedding', { 31 | method: 'POST', 32 | headers: { 33 | 'Content-Type': 'application/json', 34 | }, 35 | body: JSON.stringify({ text: searchText }), 36 | }); 37 | 38 | if (!response.ok) { 39 | throw new Error('Failed to generate embedding'); 40 | } 41 | 42 | const data = await response.json(); 43 | setEmbedding(data.embedding); 44 | } catch (error) { 45 | console.error('Error generating embedding:', error); 46 | setEmbedding(null); 47 | } finally { 48 | setIsGeneratingEmbedding(false); 49 | } 50 | } 51 | 52 | generateEmbedding(); 53 | }, [searchText]); 54 | 55 | // Use the regular messages hook with embedding when available 56 | const messagesQuery = useLLMMessages({ 57 | ...filters, 58 | ...(embedding ? { 59 | embedding: embedding, 60 | similarity_threshold: 0.6 61 | } : {}) 62 | }); 63 | 64 | const handleSearch = (e: React.FormEvent) => { 65 | e.preventDefault(); 66 | setSearchText(searchInput.trim() || null); 67 | }; 68 | 69 | // Handle input change and trigger search when input becomes empty 70 | const handleInputChange = (e: React.ChangeEvent) => { 71 | const newValue = e.target.value; 72 | setSearchInput(newValue); 73 | 74 | // If the input becomes empty, clear the search 75 | if (!newValue.trim()) { 76 | setSearchText(null); 77 | } 78 | }; 79 | 80 | return ( 81 |
82 |
83 |
84 |
85 | 91 | { 98 | if (e.key === 'Enter') { 99 | e.preventDefault(); 100 | handleSearch(e); 101 | } 102 | }} 103 | /> 104 | 110 |
111 | {/* {searchText && ( 112 | 122 | )} */} 123 |
124 |
125 | 126 | {/* Table container - make it fill the available space */} 127 |
128 | {isLoading ? ( 129 |
130 |
131 |
132 | ) : ( 133 | 138 | )} 139 |
140 |
141 | ); 142 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/containers/SparkChartContainer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import SparkChart from '../components/SparkChart'; 4 | 5 | interface DataPoint { 6 | date: string; 7 | category: string; 8 | avg_duration: number; 9 | total_requests: number; 10 | total_tokens: number; 11 | } 12 | 13 | interface SparkChartData { 14 | data: DataPoint[]; 15 | } 16 | 17 | interface SparkChartContainerProps { 18 | data: SparkChartData; 19 | isLoading: boolean; 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | chartType?: any; 22 | metric: 'avg_duration' | 'total_requests' | 'total_tokens'; 23 | title: string; 24 | className?: string; 25 | unit?: string; 26 | } 27 | 28 | export default function SparkChartContainer({ 29 | data, 30 | isLoading, 31 | chartType = 'area', 32 | metric, 33 | title, 34 | className, 35 | unit = '' 36 | }: SparkChartContainerProps) { 37 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 | let transformedData: any; 39 | let categories: string[] = []; 40 | let formattedValue: string = ''; 41 | let metricValue: number = 0; 42 | let dates: string[] = []; 43 | 44 | if (!isLoading) { 45 | // Get unique dates and categories 46 | dates = [...new Set(data.data.map((d: DataPoint) => d.date))].sort(); 47 | categories = [...new Set(data.data.map((d: DataPoint) => d.category))]; 48 | 49 | // Transform data for the chart 50 | transformedData = dates.map(date => { 51 | const dayData = data.data.filter((d: DataPoint) => d.date === date); 52 | return { 53 | date: new Date(date).toLocaleDateString('en-US', { 54 | month: 'short', 55 | day: '2-digit' 56 | }), 57 | ...categories.reduce((acc, category) => ({ 58 | ...acc, 59 | [category]: dayData.find(d => d.category === category)?.[metric] || 0 60 | }), {}) 61 | }; 62 | }); 63 | 64 | // Calculate metric average/total 65 | metricValue = data.data.reduce((sum, curr) => sum + curr[metric], 0); 66 | formattedValue = metric === 'avg_duration' 67 | ? `${(metricValue / data.data.length).toFixed(2)} s` 68 | : metric === 'total_tokens' 69 | ? `${metricValue.toLocaleString()} tokens` 70 | : metricValue.toLocaleString(); 71 | } 72 | 73 | return ( 74 | 84 | ); 85 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/containers/TimeseriesChartContainer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import TimeseriesChart from '../components/TimeseriesChart'; 4 | 5 | interface TimeseriesData { 6 | date: string; 7 | category: string; // model name 8 | total_requests: number; 9 | total_errors: number; 10 | total_tokens: number; 11 | total_completion_tokens: number; 12 | total_prompt_tokens: number; 13 | total_cost: number; 14 | avg_duration: number; 15 | avg_response_time: number; 16 | } 17 | 18 | interface TimeseriesChartContainerProps { 19 | data: { data: TimeseriesData[] }; 20 | isLoading: boolean; 21 | filters: Record; 22 | onFiltersChange: (filters: Record) => void; 23 | } 24 | 25 | export default function TimeseriesChartContainer({ 26 | data, 27 | isLoading, 28 | filters, 29 | onFiltersChange 30 | }: TimeseriesChartContainerProps) { 31 | // Return the original TimeseriesChart component 32 | return ( 33 |
34 | } 38 | onFiltersChange={onFiltersChange} 39 | /> 40 |
41 | ); 42 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/context/ModalContext.tsx: -------------------------------------------------------------------------------- 1 | // src/app/context/ModalContext.tsx 2 | 'use client'; 3 | 4 | import { createContext, useContext, useState, ReactNode } from 'react'; 5 | 6 | interface ModalContextType { 7 | isCostPredictionOpen: boolean; 8 | openCostPrediction: () => void; 9 | closeCostPrediction: () => void; 10 | } 11 | 12 | const ModalContext = createContext(undefined); 13 | 14 | export function ModalProvider({ children }: { children: ReactNode }) { 15 | const [isCostPredictionOpen, setIsCostPredictionOpen] = useState(false); 16 | 17 | const openCostPrediction = () => setIsCostPredictionOpen(true); 18 | const closeCostPrediction = () => setIsCostPredictionOpen(false); 19 | 20 | return ( 21 | 26 | {children} 27 | 28 | ); 29 | } 30 | 31 | export function useModal() { 32 | const context = useContext(ModalContext); 33 | if (context === undefined) { 34 | throw new Error('useModal must be used within a ModalProvider'); 35 | } 36 | return context; 37 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/context/OnboardingContext.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { createContext, useContext, useState, useEffect } from 'react' 4 | 5 | interface OnboardingContextType { 6 | showOnboarding: boolean 7 | setShowOnboarding: (show: boolean) => void 8 | currentStep: number 9 | setCurrentStep: (step: number) => void 10 | } 11 | 12 | const OnboardingContext = createContext(undefined) 13 | 14 | export function OnboardingProvider({ children }: { children: React.ReactNode }) { 15 | const [showOnboarding, setShowOnboarding] = useState(false) 16 | const [currentStep, setCurrentStep] = useState(0) 17 | 18 | useEffect(() => { 19 | const hasSeenOnboarding = localStorage.getItem('hasSeenOnboarding') 20 | if (!hasSeenOnboarding) { 21 | setShowOnboarding(true) 22 | localStorage.setItem('hasSeenOnboarding', 'true') 23 | } 24 | }, []) 25 | 26 | return ( 27 | 35 | {children} 36 | 37 | ) 38 | } 39 | 40 | export function useOnboarding() { 41 | const context = useContext(OnboardingContext) 42 | if (context === undefined) { 43 | throw new Error('useOnboarding must be used within an OnboardingProvider') 44 | } 45 | return context 46 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinybirdco/llm-performance-tracker/910edc8a27a34e2cfe7f19e6b698828fe0eaf910/dashboard/ai-analytics/src/app/favicon.ico -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .onboarding-highlight { 6 | position: relative; 7 | z-index: 51; 8 | } 9 | 10 | .onboarding-highlight[data-search-input], 11 | .onboarding-highlight[data-table-search] { 12 | border: 1px solid #F4F4F4; 13 | } 14 | 15 | /* Design System */ 16 | :root { 17 | /* Colors */ 18 | --background: #0a0a0a; 19 | --foreground: #ededed; 20 | --text-color: #F4F4F4; 21 | --text-color-secondary: #C6C6C6; 22 | --accent: #27F795; 23 | --hover-accent: #267A52; 24 | --main-button-text-color: var(--background); 25 | 26 | /* Component Colors */ 27 | --date-range-bg: #353535; 28 | --date-range-text: #F4F4F4; 29 | --date-range-divider: rgba(255, 255, 255, 0.1); 30 | 31 | /* Typography */ 32 | --font-family-base: 'Roboto', sans-serif; 33 | --font-family-mono: 'Roboto Mono', monospace; 34 | --font-weight-normal: 400; 35 | --font-size-xs: 12px; 36 | --font-size-sm: 14px; 37 | --font-size-base: 16px; 38 | --font-size-title: 24px; 39 | --line-height-sm: 20px; 40 | --line-height-base: 24px; 41 | --line-height-xs: 16px; 42 | --letter-spacing: 0.16px; 43 | } 44 | 45 | @theme inline { 46 | --color-background: var(--background); 47 | --color-foreground: var(--foreground); 48 | --font-sans: var(--font-geist-sans); 49 | --font-mono: var(--font-geist-mono); 50 | } 51 | 52 | body { 53 | background: var(--background); 54 | color: var(--foreground); 55 | font-family: Arial, Helvetica, sans-serif; 56 | } 57 | 58 | .title-font { 59 | font-family: var(--font-family-base); 60 | color: var(--text-color); 61 | font-size: var(--font-size-title); 62 | line-height: var(--line-height-sm); 63 | } 64 | 65 | .default-font { 66 | font-family: var(--font-family-base); 67 | color: var(--text-color); 68 | font-size: var(--font-size-sm); 69 | line-height: var(--line-height-sm); 70 | } 71 | 72 | .dropdown-font { 73 | font-family: var(--font-family-base); 74 | color: #C6C6C6; 75 | font-size: var(--font-size-sm); 76 | line-height: var(--line-height-sm); 77 | } 78 | 79 | .small-font { 80 | font-family: var(--font-family-base); 81 | color: var(--text-color-secondary); 82 | font-size: var(--font-size-xs); 83 | line-height: var(--line-height-xs); 84 | } 85 | 86 | .button-font { 87 | font-family: var(--font-family-base); 88 | color: var(--background); 89 | font-size: var(--font-size-sm); 90 | line-height: var(--line-height-base); 91 | } 92 | /* Component Styles - keeping original implementation */ 93 | .ai-calculator-button { 94 | /* Auto layout */ 95 | display: inline-flex; 96 | flex-direction: row; 97 | align-items: center; 98 | padding: 12px 24px; 99 | gap: 8px; 100 | height: 48px; 101 | 102 | background: var(--accent); 103 | color: var(--main-button-text-color); 104 | white-space: nowrap; 105 | 106 | /* Text styles */ 107 | font-family: var(--font-family-base); 108 | font-style: normal; 109 | font-weight: var(--font-weight-normal); 110 | font-size: var(--font-size-base); 111 | line-height: var(--line-height-base); 112 | 113 | /* Inside auto layout */ 114 | flex: none; 115 | order: 0; 116 | flex-grow: 0; 117 | transition: background-color 0.2s; 118 | } 119 | 120 | .ai-calculator-button:hover { 121 | background: rgba(39, 247, 149, 0.9); 122 | } 123 | 124 | /* Date Range Selector styles */ 125 | .date-range-selector { 126 | display: flex; 127 | flex-direction: row; 128 | align-items: center; 129 | padding: 14px 16px; 130 | gap: 8px; 131 | 132 | width: 288px; 133 | height: 48px; 134 | 135 | background: var(--date-range-bg); 136 | 137 | /* Inside auto layout */ 138 | flex: none; 139 | order: 2; 140 | flex-grow: 0; 141 | } 142 | 143 | .date-range-content { 144 | display: flex; 145 | align-items: center; 146 | gap: 8px; 147 | width: 100%; 148 | } 149 | 150 | .date-range-selector button { 151 | background: transparent !important; 152 | border: none !important; 153 | color: var(--date-range-text) !important; 154 | display: inline-flex !important; 155 | align-items: center !important; 156 | } 157 | 158 | .date-range-text { 159 | font-family: var(--font-family-base); 160 | font-style: normal; 161 | font-weight: var(--font-weight-normal); 162 | font-size: var(--font-size-sm); 163 | line-height: var(--line-height-sm); 164 | letter-spacing: var(--letter-spacing); 165 | color: var(--date-range-text); 166 | height: var(--line-height-sm); 167 | display: inline-flex; 168 | align-items: center; 169 | } 170 | 171 | /* Style for the divider between buttons */ 172 | .date-range-selector-divider { 173 | width: 1px; 174 | height: 24px; 175 | background: var(--date-range-divider); 176 | } 177 | 178 | /* Filter input styles - matching date range selector */ 179 | .filter-input-wrapper { 180 | display: flex; 181 | flex-direction: row; 182 | align-items: center; 183 | padding: 14px 16px; 184 | gap: 8px; 185 | 186 | width: 288px; 187 | height: 48px; 188 | 189 | background: #353535; 190 | 191 | /* Inside auto layout */ 192 | flex: none; 193 | order: 1; 194 | flex-grow: 0; 195 | } 196 | 197 | .filter-input { 198 | width: 100%; 199 | background: transparent; 200 | border: none; 201 | color: #F4F4F4; 202 | font-family: 'Roboto', sans-serif; 203 | font-size: 14px; 204 | line-height: 20px; 205 | padding: 0; 206 | } 207 | 208 | /* Apply placeholder styles to all input types */ 209 | input::placeholder, 210 | textarea::placeholder, 211 | select::placeholder { 212 | color: #C6C6C6 !important; 213 | opacity: 1; 214 | font-family: var(--font-family-base) !important; 215 | font-size: var(--font-size-sm) !important; 216 | line-height: var(--line-height-sm) !important; 217 | } 218 | 219 | /* Ensure placeholder styles work in Firefox */ 220 | ::-moz-placeholder { 221 | color: #C6C6C6 !important; 222 | opacity: 1; 223 | } 224 | 225 | /* Ensure placeholder styles work in IE */ 226 | :-ms-input-placeholder { 227 | color: #C6C6C6 !important; 228 | opacity: 1; 229 | } 230 | 231 | .filter-input:focus { 232 | outline: none; 233 | box-shadow: none; 234 | } 235 | 236 | .filter-input:disabled { 237 | opacity: 0.5; 238 | } 239 | 240 | .settings-button, .floating-notification-button { 241 | display: flex; 242 | flex-direction: row; 243 | align-items: center; 244 | padding: 14px 16px; 245 | gap: 8px; 246 | 247 | height: 48px; 248 | 249 | background: #353535; 250 | 251 | /* Inside auto layout */ 252 | flex: none; 253 | order: 0; 254 | flex-grow: 0; 255 | transition: all 0.2s ease; 256 | border: 1px solid transparent; 257 | } 258 | 259 | .settings-button:hover { 260 | background: var(--accent); 261 | border: 1px solid transparent; 262 | color: var(--background); 263 | } 264 | 265 | .settings-button:hover * { 266 | color: var(--background); 267 | } 268 | 269 | /* Tremor Tab styles override */ 270 | .tremor-TabList-root [aria-selected="true"], 271 | .tremor-TabList-root [data-headlessui-state*="selected"], 272 | .tremor-Tab-root[aria-selected="true"], 273 | .tremor-Tab-root[data-headlessui-state*="selected"] { 274 | background-color: #262626 !important; 275 | border-bottom: 2px solid #27F795 !important; 276 | color: #F4F4F4 !important; 277 | } 278 | 279 | /* Add this to ensure no other styles are overriding */ 280 | .tremor-Tab-root[aria-selected="true"]::after, 281 | .tremor-Tab-root[data-headlessui-state*="selected"]::after { 282 | display: none !important; 283 | } 284 | 285 | .tremor-Tab-root { 286 | min-width: 91.5px !important; 287 | width: auto !important; 288 | height: 32px !important; 289 | padding: 16px 16px !important; 290 | margin: 0 !important; 291 | display: flex !important; 292 | align-items: center !important; 293 | justify-content: center !important; 294 | font-size: 14px !important; 295 | color: #C6C6C6 !important; 296 | white-space: nowrap !important; 297 | font-family: 'Roboto', sans-serif !important; 298 | } 299 | 300 | .tremor-Tab-root:hover { 301 | color: #F4F4F4 !important; 302 | } 303 | 304 | .tremor-TabList-root { 305 | border: none !important; 306 | background: transparent !important; 307 | } 308 | 309 | /* Tremor Chart Axis Labels */ 310 | .recharts-text tspan { 311 | fill: #C6C6C6 !important; 312 | color: #C6C6C6 !important; 313 | font-size: 12px !important; 314 | } 315 | 316 | /* Bar Chart styles */ 317 | .tremor-BarChart rect[role="graphics-symbol"] { 318 | stroke: #000000; 319 | stroke-width: 1px; 320 | transition: opacity 0.2s ease; 321 | } 322 | 323 | ::selection { 324 | background-color: var(--accent); /* Your accent color */ 325 | color: var(--background); 326 | } 327 | 328 | ::-moz-selection { 329 | background-color: var(--accent); /* Your accent color */ 330 | color: var(--background); 331 | } 332 | 333 | /* Search Input Styles */ 334 | .search-input-container { 335 | position: relative; 336 | width: 288px; 337 | } 338 | 339 | .search-input { 340 | width: 100%; 341 | height: 48px; 342 | padding: 0 8px; 343 | background: var(--tremor-background-subtle); 344 | border: 1px solid transparent; 345 | border-radius: 0px; 346 | color: var(--tremor-content); 347 | font-family: 'Roboto', sans-serif; 348 | font-size: 14px; 349 | transition: all 0.2s ease; 350 | } 351 | 352 | .search-input:focus { 353 | outline: none; 354 | border-color: var(--accent); 355 | } 356 | 357 | .search-input::placeholder { 358 | color: var(--tremor-content); 359 | opacity: 0.7; 360 | transition: opacity 0.2s ease; 361 | } 362 | 363 | .search-input:focus::placeholder { 364 | opacity: 0; 365 | } 366 | 367 | .search-input-left-icon { 368 | position: absolute; 369 | left: 8px; 370 | top: 50%; 371 | transform: translateY(-50%); 372 | color: var(--tremor-content); 373 | opacity: 0.7; 374 | } 375 | 376 | .search-input-right-icon { 377 | position: absolute; 378 | right: 16px; 379 | top: 50%; 380 | transform: translateY(-50%); 381 | color: var(--tremor-content); 382 | transition: all 0.2s ease; 383 | } 384 | 385 | .search-input:focus ~ .search-input-right-icon { 386 | color: var(--accent); 387 | opacity: 1; 388 | } 389 | 390 | .search-input-right-icon.animate { 391 | animation: sparkle 1s ease-in-out infinite; 392 | color: var(--accent); 393 | } 394 | 395 | @keyframes sparkle { 396 | 0% { 397 | opacity: 0.7; 398 | } 399 | 50% { 400 | opacity: 1; 401 | } 402 | 100% { 403 | opacity: 0.7; 404 | } 405 | } 406 | 407 | @keyframes pulse { 408 | 0% { 409 | transform: scale(1); 410 | box-shadow: 0 0 0 0 rgba(39, 247, 149, 0.4); 411 | } 412 | 70% { 413 | transform: scale(1.05); 414 | box-shadow: 0 0 0 10px rgba(39, 247, 149, 0); 415 | } 416 | 100% { 417 | transform: scale(1); 418 | box-shadow: 0 0 0 0 rgba(39, 247, 149, 0); 419 | } 420 | } 421 | 422 | .floating-notification-highlight { 423 | animation: pulse 2s infinite; 424 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/globals.css_deleteme: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @theme inline { 6 | --color-background: var(--background); 7 | --color-foreground: var(--foreground); 8 | --font-sans: var(--font-geist-sans); 9 | --font-mono: var(--font-geist-mono); 10 | } 11 | 12 | 13 | 14 | @layer base { 15 | :root { 16 | --background: 0 0% 100%; 17 | --foreground: 0 0% 3.9%; 18 | --card: 0 0% 100%; 19 | --card-foreground: 0 0% 3.9%; 20 | --popover: 0 0% 100%; 21 | --popover-foreground: 0 0% 3.9%; 22 | --primary: 0 0% 9%; 23 | --primary-foreground: 0 0% 98%; 24 | --secondary: 0 0% 96.1%; 25 | --secondary-foreground: 0 0% 9%; 26 | --muted: 0 0% 96.1%; 27 | --muted-foreground: 0 0% 45.1%; 28 | --accent: 0 0% 96.1%; 29 | --accent-foreground: 0 0% 9%; 30 | --destructive: 0 84.2% 60.2%; 31 | --destructive-foreground: 0 0% 98%; 32 | --border: 0 0% 89.8%; 33 | --input: 0 0% 89.8%; 34 | --ring: 0 0% 3.9%; 35 | --chart-1: 12 76% 61%; 36 | --chart-2: 173 58% 39%; 37 | --chart-3: 197 37% 24%; 38 | --chart-4: 43 74% 66%; 39 | --chart-5: 27 87% 67%; 40 | --radius: 0.5rem; 41 | } 42 | .dark { 43 | --background: 0 0% 3.9%; 44 | --foreground: 0 0% 98%; 45 | --card: 0 0% 3.9%; 46 | --card-foreground: 0 0% 98%; 47 | --popover: 0 0% 3.9%; 48 | --popover-foreground: 0 0% 98%; 49 | --primary: 0 0% 98%; 50 | --primary-foreground: 0 0% 9%; 51 | --secondary: 0 0% 14.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | --muted: 0 0% 14.9%; 54 | --muted-foreground: 0 0% 63.9%; 55 | --accent: 0 0% 14.9%; 56 | --accent-foreground: 0 0% 98%; 57 | --destructive: 0 62.8% 30.6%; 58 | --destructive-foreground: 0 0% 98%; 59 | --border: 0 0% 14.9%; 60 | --input: 0 0% 14.9%; 61 | --ring: 0 0% 83.1%; 62 | --chart-1: 220 70% 50%; 63 | --chart-2: 160 60% 45%; 64 | --chart-3: 30 80% 55%; 65 | --chart-4: 280 65% 60%; 66 | --chart-5: 340 75% 55%; 67 | } 68 | } 69 | 70 | 71 | 72 | @layer base { 73 | * { 74 | @apply border-border; 75 | } 76 | body { 77 | @apply bg-background text-foreground; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | import { Roboto, Roboto_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { TinybirdProvider } from '@/providers/TinybirdProvider'; 5 | import { ClerkProvider } from '@clerk/nextjs'; 6 | import { ModalProvider } from './context/ModalContext'; 7 | import { OnboardingProvider } from './context/OnboardingContext'; 8 | import { RootLayoutContent } from './components/RootLayoutContent'; 9 | import RibbonsWrapper from '@/components/RibbonsWrapper'; 10 | 11 | const roboto = Roboto({ 12 | weight: ['400'], 13 | subsets: ['latin'], 14 | }); 15 | const robotoMono = Roboto_Mono({ 16 | weight: ['400'], 17 | subsets: ['latin'], 18 | }); 19 | 20 | export default async function RootLayout({ children }: { children: React.ReactNode }) { 21 | const headersList = await headers(); 22 | const token = headersList.get('x-tinybird-token') || ''; 23 | const orgName = headersList.get('x-org-name') || ''; 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {children} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import TopBar from './components/TopBar'; 4 | import TimeseriesChartContainer from './containers/TimeseriesChartContainer'; 5 | import MetricsCards from './components/MetricsCards'; 6 | import DataTableContainer from './containers/DataTableContainer'; 7 | import TabbedPane from './components/TabbedPane'; 8 | import { useState, useEffect, Suspense } from 'react'; 9 | import { useSearchParams } from 'next/navigation'; 10 | import { tabs } from './constants'; 11 | import { useLLMUsage } from '@/hooks/useTinybirdData'; 12 | import ResizableSplitView from './components/ResizableSplitView'; 13 | 14 | interface Selection { 15 | dimension: string; 16 | dimensionName: string; 17 | values: string[]; 18 | } 19 | 20 | export default function Dashboard() { 21 | return ( 22 | Loading...
}> 23 | 24 | 25 | ); 26 | } 27 | 28 | function DashboardContent() { 29 | const [filters, setFilters] = useState>({ 30 | column_name: 'model' // Set default column_name 31 | }); 32 | const [selections, setSelections] = useState([]); 33 | const searchParams = useSearchParams(); 34 | 35 | // Shared LLM usage data 36 | const { data: llmData, isLoading } = useLLMUsage(filters); 37 | 38 | // Initialize from URL only once 39 | useEffect(() => { 40 | const params = new URLSearchParams(searchParams.toString()); 41 | const newSelections: Selection[] = []; 42 | const newFilters: Record = { 43 | column_name: 'model' 44 | }; 45 | 46 | // Check for user filter first 47 | const userFilter = params.get('user'); 48 | if (userFilter) { 49 | // If user filter is active, set the user filter with the actual hash value 50 | newFilters.user = userFilter; 51 | } 52 | 53 | // Get column_name from URL if present (override default) 54 | const columnName = params.get('column_name'); 55 | if (columnName) { 56 | newFilters.column_name = columnName; 57 | } 58 | 59 | // Add date range parameters to filters 60 | const startDate = params.get('start_date'); 61 | const endDate = params.get('end_date'); 62 | if (startDate) newFilters.start_date = startDate; 63 | if (endDate) newFilters.end_date = endDate; 64 | 65 | // Check each possible dimension from tabs 66 | tabs.forEach(tab => { 67 | const values = params.get(tab.key)?.split(',') || []; 68 | if (values.length > 0) { 69 | newSelections.push({ 70 | dimension: tab.key, 71 | dimensionName: tab.name, 72 | values 73 | }); 74 | newFilters[tab.key] = values.join(','); 75 | } 76 | }); 77 | 78 | setSelections(newSelections); 79 | setFilters(newFilters); 80 | }, [searchParams]); // This should run whenever searchParams changes 81 | 82 | const handleFilterUpdate = (dimension: string, dimensionName: string, values: string[]) => { 83 | setSelections(prev => { 84 | const otherSelections = prev.filter(s => s.dimension !== dimension); 85 | return values.length > 0 86 | ? [...otherSelections, { dimension, dimensionName, values }] 87 | : otherSelections; 88 | }); 89 | 90 | setFilters(prev => { 91 | const newFilters = { ...prev }; 92 | if (values.length > 0) { 93 | newFilters[dimension] = values.join(','); 94 | } else { 95 | delete newFilters[dimension]; 96 | } 97 | return newFilters; 98 | }); 99 | }; 100 | 101 | const handleRemoveFilter = (dimension: string, value: string) => { 102 | setSelections(prev => { 103 | const newSelections = prev.map(selection => { 104 | if (selection.dimension === dimension) { 105 | const newValues = selection.values.filter(v => v !== value); 106 | if (newValues.length === 0) return null; 107 | return { ...selection, values: newValues }; 108 | } 109 | return selection; 110 | }).filter((s): s is Selection => s !== null); 111 | 112 | setFilters(prev => { 113 | const newFilters = { ...prev }; 114 | const selection = newSelections.find(s => s.dimension === dimension); 115 | if (selection) { 116 | newFilters[dimension] = selection.values.join(','); 117 | } else { 118 | delete newFilters[dimension]; 119 | } 120 | return newFilters; 121 | }); 122 | 123 | return newSelections; 124 | }); 125 | }; 126 | 127 | const handleTimeseriesFilterChange = (newFilters: Record) => { 128 | // Preserve the user filter if it exists in the current filters 129 | if (filters.user) { 130 | newFilters.user = filters.user; 131 | } 132 | 133 | setFilters(newFilters); 134 | }; 135 | 136 | return ( 137 |
138 | 142 | 143 |
144 | {/* Main Content - 2/3 width */} 145 |
146 |
147 | 155 | } 156 | bottomComponent={ 157 | 161 | } 162 | initialTopHeight="40vh" 163 | minTopHeight="30vh" 164 | minBottomHeight="20vh" 165 | /> 166 |
167 |
168 | 169 | {/* Sidebar - 1/3 width */} 170 |
171 |
172 | 176 |
177 |
178 | 182 |
183 |
184 |
185 |
186 | ); 187 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import ApiKeyInput from '@/app/components/ApiKeyInput'; 4 | 5 | export default function SettingsPage() { 6 | return ( 7 |
8 |

Settings

9 | 10 |
11 | {}} /> 12 | 13 | {/* Other settings can go here */} 14 |
15 |
16 | ); 17 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/components/RibbonsWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import Ribbons from '@/components/ui/ribbons'; 5 | 6 | const KONAMI_CODE = [ 7 | 'ArrowUp', 8 | 'ArrowUp', 9 | 'ArrowDown', 10 | 'ArrowDown', 11 | 'ArrowLeft', 12 | 'ArrowRight', 13 | 'ArrowLeft', 14 | 'ArrowRight', 15 | 'b', 16 | 'a' 17 | ]; 18 | 19 | export default function RibbonsWrapper() { 20 | const [isVisible, setIsVisible] = useState(false); 21 | const [konamiProgress, setKonamiProgress] = useState(0); 22 | 23 | // Handle Konami code 24 | useEffect(() => { 25 | const handleKeyDown = (event: KeyboardEvent) => { 26 | const key = event.key.toLowerCase(); 27 | const expectedKey = KONAMI_CODE[konamiProgress].toLowerCase(); 28 | 29 | if (key === expectedKey) { 30 | const newProgress = konamiProgress + 1; 31 | setKonamiProgress(newProgress); 32 | 33 | if (newProgress === KONAMI_CODE.length) { 34 | setIsVisible(true); 35 | setKonamiProgress(0); 36 | } 37 | } else { 38 | setKonamiProgress(0); 39 | } 40 | }; 41 | 42 | window.addEventListener('keydown', handleKeyDown); 43 | return () => window.removeEventListener('keydown', handleKeyDown); 44 | }, [konamiProgress]); 45 | 46 | if (!isVisible) return null; 47 | 48 | return ( 49 |
50 | 58 |
59 | ); 60 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ChevronLeft, ChevronRight } from "lucide-react" 5 | import { DayPicker } from "react-day-picker" 6 | 7 | import { cn } from "@/lib/utils" 8 | import { buttonVariants } from "@/components/ui/button" 9 | 10 | export type CalendarProps = React.ComponentProps 11 | 12 | function Calendar({ 13 | className, 14 | classNames, 15 | showOutsideDays = true, 16 | ...props 17 | }: CalendarProps) { 18 | return ( 19 | .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" 43 | : "[&:has([aria-selected])]:rounded-md" 44 | ), 45 | day: cn( 46 | buttonVariants({ variant: "ghost" }), 47 | "h-8 w-8 p-0 font-normal aria-selected:opacity-100" 48 | ), 49 | day_range_start: "day-range-start", 50 | day_range_end: "day-range-end", 51 | day_selected: 52 | "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", 53 | day_today: "bg-accent text-accent-foreground", 54 | day_outside: 55 | "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", 56 | day_disabled: "text-muted-foreground opacity-50", 57 | day_range_middle: 58 | "aria-selected:bg-accent aria-selected:text-accent-foreground", 59 | day_hidden: "invisible", 60 | ...classNames, 61 | }} 62 | components={{ 63 | IconLeft: ({ className, ...props }) => ( 64 | 65 | ), 66 | IconRight: ({ className, ...props }) => ( 67 | 68 | ), 69 | }} 70 | {...props} 71 | /> 72 | ) 73 | } 74 | Calendar.displayName = "Calendar" 75 | 76 | export { Calendar } 77 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/components/ui/floating-notification.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useRef, useEffect } from 'react' 4 | import { cn } from '@/lib/utils' 5 | import { X, HelpCircle } from 'lucide-react' 6 | import { TinybirdIcon, GithubIcon } from '@/app/components/icons' 7 | import { OnboardingModal } from './onboarding-modal' 8 | 9 | interface FloatingNotificationProps { 10 | className?: string 11 | title?: string 12 | links?: { 13 | github?: string 14 | tinybird?: string 15 | close?: () => void 16 | } 17 | hideSignIn?: boolean 18 | } 19 | 20 | export function FloatingNotification({ 21 | className, 22 | title = 'Fork and deploy your own LLM tracker', 23 | links = {}, 24 | hideSignIn = false, 25 | }: FloatingNotificationProps) { 26 | const [isCollapsed, setIsCollapsed] = useState(false) 27 | const [position, setPosition] = useState({ x: 16, y: 0 }) 28 | const [isDragging, setIsDragging] = useState(false) 29 | const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) 30 | const [showOnboarding, setShowOnboarding] = useState(false) 31 | const containerRef = useRef(null) 32 | 33 | // Set initial position and handle window resize 34 | useEffect(() => { 35 | const updatePosition = () => { 36 | setPosition({ 37 | x: 16, 38 | y: window.innerHeight - 64 // 64px is the height of the notification 39 | }) 40 | } 41 | 42 | // Set initial position 43 | updatePosition() 44 | 45 | // Add resize listener 46 | window.addEventListener('resize', updatePosition) 47 | return () => window.removeEventListener('resize', updatePosition) 48 | }, []) 49 | 50 | // Show onboarding modal on page load 51 | useEffect(() => { 52 | if (!hideSignIn) { 53 | setShowOnboarding(true) 54 | } 55 | }, [hideSignIn]) 56 | 57 | const handleMouseDown = (e: React.MouseEvent) => { 58 | if (containerRef.current) { 59 | const rect = containerRef.current.getBoundingClientRect() 60 | setDragOffset({ 61 | x: e.clientX - rect.left, 62 | y: e.clientY - rect.top, 63 | }) 64 | setIsDragging(true) 65 | } 66 | } 67 | 68 | useEffect(() => { 69 | const handleMouseMove = (e: MouseEvent) => { 70 | if (isDragging) { 71 | setPosition({ 72 | x: e.clientX - dragOffset.x, 73 | y: e.clientY - dragOffset.y, 74 | }) 75 | } 76 | } 77 | 78 | const handleMouseUp = () => { 79 | setIsDragging(false) 80 | } 81 | 82 | if (isDragging) { 83 | document.addEventListener('mousemove', handleMouseMove) 84 | document.addEventListener('mouseup', handleMouseUp) 85 | } 86 | 87 | return () => { 88 | document.removeEventListener('mousemove', handleMouseMove) 89 | document.removeEventListener('mouseup', handleMouseUp) 90 | } 91 | }, [isDragging, dragOffset]) 92 | 93 | if (hideSignIn) return null; 94 | 95 | return ( 96 | <> 97 |
98 |
110 | 116 | 117 | {!isCollapsed && ( 118 | <> 119 |
{title}
120 |
121 | {links.tinybird && ( 122 | 127 | 128 | 129 | )} 130 | {links.github && ( 131 | 136 | 137 | 138 | )} 139 |
140 | 141 | )} 142 | 143 | 153 |
154 |
155 | 156 | setShowOnboarding(false)} 159 | /> 160 | 161 | ) 162 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )) 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 34 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/components/ui/ribbons.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React, { useEffect, useRef, useState } from 'react'; 3 | import { Renderer, Transform, Vec3, Color, Polyline } from 'ogl'; 4 | 5 | interface RibbonsProps { 6 | colors?: string[]; 7 | baseSpring?: number; 8 | baseFriction?: number; 9 | baseThickness?: number; 10 | offsetFactor?: number; 11 | maxAge?: number; 12 | pointCount?: number; 13 | speedMultiplier?: number; 14 | enableFade?: boolean; 15 | enableShaderEffect?: boolean; 16 | effectAmplitude?: number; 17 | backgroundColor?: number[]; 18 | } 19 | 20 | const KONAMI_CODE = [ 21 | 'ArrowUp', 22 | 'ArrowUp', 23 | 'ArrowDown', 24 | 'ArrowDown', 25 | 'ArrowLeft', 26 | 'ArrowRight', 27 | 'ArrowLeft', 28 | 'ArrowRight', 29 | 'b', 30 | 'a' 31 | ]; 32 | 33 | const Ribbons: React.FC = ({ 34 | colors = ['#ff9346', '#7cff67', '#ffee51', '#00d8ff'], 35 | baseSpring = 0.03, 36 | baseFriction = 0.9, 37 | baseThickness = 30, 38 | offsetFactor = 0.05, 39 | maxAge = 500, 40 | pointCount = 50, 41 | speedMultiplier = 0.6, 42 | enableFade = false, 43 | enableShaderEffect = false, 44 | effectAmplitude = 2, 45 | backgroundColor = [0, 0, 0, 0], 46 | }) => { 47 | const containerRef = useRef(null); 48 | const [isVisible, setIsVisible] = useState(false); 49 | const [konamiProgress, setKonamiProgress] = useState(0); 50 | 51 | useEffect(() => { 52 | const handleKeyDown = (event: KeyboardEvent) => { 53 | const key = event.key.toLowerCase(); 54 | const expectedKey = KONAMI_CODE[konamiProgress].toLowerCase(); 55 | 56 | if (key === expectedKey) { 57 | const newProgress = konamiProgress + 1; 58 | setKonamiProgress(newProgress); 59 | 60 | if (newProgress === KONAMI_CODE.length) { 61 | setIsVisible(true); 62 | setKonamiProgress(0); 63 | } 64 | } else { 65 | setKonamiProgress(0); 66 | } 67 | }; 68 | 69 | window.addEventListener('keydown', handleKeyDown); 70 | return () => window.removeEventListener('keydown', handleKeyDown); 71 | }, [konamiProgress]); 72 | 73 | useEffect(() => { 74 | if (!isVisible) return; 75 | 76 | const container = containerRef.current; 77 | if (!container) return; 78 | 79 | // Create a renderer with an alpha-enabled context 80 | const renderer = new Renderer({ 81 | dpr: window.devicePixelRatio || 2, 82 | alpha: true, 83 | antialias: true 84 | }); 85 | const gl = renderer.gl; 86 | gl.clearColor(0, 0, 0, 0); 87 | 88 | // Set up canvas styles 89 | gl.canvas.style.position = 'fixed'; 90 | gl.canvas.style.top = '0'; 91 | gl.canvas.style.left = '0'; 92 | gl.canvas.style.width = '100vw'; 93 | gl.canvas.style.height = '100vh'; 94 | gl.canvas.style.display = 'block'; 95 | container.appendChild(gl.canvas); 96 | 97 | // Set initial size 98 | renderer.setSize(window.innerWidth, window.innerHeight); 99 | 100 | const scene = new Transform(); 101 | const lines: { 102 | spring: number; 103 | friction: number; 104 | mouseVelocity: Vec3; 105 | mouseOffset: Vec3; 106 | points: Vec3[]; 107 | polyline: Polyline; 108 | }[] = []; 109 | 110 | const vertex = ` 111 | precision highp float; 112 | 113 | attribute vec3 position; 114 | attribute vec3 next; 115 | attribute vec3 prev; 116 | attribute vec2 uv; 117 | attribute float side; 118 | 119 | uniform vec2 uResolution; 120 | uniform float uDPR; 121 | uniform float uThickness; 122 | uniform float uTime; 123 | uniform float uEnableShaderEffect; 124 | uniform float uEffectAmplitude; 125 | 126 | varying vec2 vUV; 127 | 128 | vec4 getPosition() { 129 | vec4 current = vec4(position, 1.0); 130 | vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0); 131 | vec2 nextScreen = next.xy * aspect; 132 | vec2 prevScreen = prev.xy * aspect; 133 | vec2 tangent = normalize(nextScreen - prevScreen); 134 | vec2 normal = vec2(-tangent.y, tangent.x); 135 | normal /= aspect; 136 | normal *= mix(1.0, 0.1, pow(abs(uv.y - 0.5) * 2.0, 2.0)); 137 | float dist = length(nextScreen - prevScreen); 138 | normal *= smoothstep(0.0, 0.02, dist); 139 | float pixelWidthRatio = 1.0 / (uResolution.y / uDPR); 140 | float pixelWidth = current.w * pixelWidthRatio; 141 | normal *= pixelWidth * uThickness; 142 | current.xy -= normal * side; 143 | if(uEnableShaderEffect > 0.5) { 144 | current.xy += normal * sin(uTime + current.x * 10.0) * uEffectAmplitude; 145 | } 146 | return current; 147 | } 148 | 149 | void main() { 150 | vUV = uv; 151 | gl_Position = getPosition(); 152 | } 153 | `; 154 | 155 | const fragment = ` 156 | precision highp float; 157 | uniform vec3 uColor; 158 | uniform float uOpacity; 159 | uniform float uEnableFade; 160 | varying vec2 vUV; 161 | void main() { 162 | float fadeFactor = 1.0; 163 | if(uEnableFade > 0.5) { 164 | fadeFactor = 1.0 - smoothstep(0.0, 1.0, vUV.y); 165 | } 166 | gl_FragColor = vec4(uColor, uOpacity * fadeFactor); 167 | } 168 | `; 169 | 170 | const mouse = new Vec3(); 171 | function updateMouse(e: MouseEvent | TouchEvent) { 172 | if ('touches' in e) { 173 | const touch = e.touches[0]; 174 | if (touch) { 175 | mouse.set( 176 | (touch.clientX / window.innerWidth) * 2 - 1, 177 | -(touch.clientY / window.innerHeight) * 2 + 1, 178 | 0 179 | ); 180 | } 181 | } else { 182 | mouse.set( 183 | (e.clientX / window.innerWidth) * 2 - 1, 184 | -(e.clientY / window.innerHeight) * 2 + 1, 185 | 0 186 | ); 187 | } 188 | } 189 | 190 | window.addEventListener('mousemove', updateMouse); 191 | window.addEventListener('touchmove', updateMouse); 192 | 193 | const center = (colors.length - 1) / 2; 194 | colors.forEach((color, index) => { 195 | const spring = baseSpring + (Math.random() - 0.5) * 0.05; 196 | const friction = baseFriction + (Math.random() - 0.5) * 0.05; 197 | const thickness = baseThickness + (Math.random() - 0.5) * 3; 198 | const mouseOffset = new Vec3( 199 | (index - center) * offsetFactor + (Math.random() - 0.5) * 0.01, 200 | (Math.random() - 0.5) * 0.1, 201 | 0 202 | ); 203 | 204 | const line = { 205 | spring, 206 | friction, 207 | mouseVelocity: new Vec3(), 208 | mouseOffset, 209 | points: [] as Vec3[], 210 | polyline: {} as Polyline, 211 | }; 212 | 213 | const count = pointCount; 214 | const points: Vec3[] = []; 215 | for (let i = 0; i < count; i++) { 216 | points.push(new Vec3()); 217 | } 218 | line.points = points; 219 | 220 | line.polyline = new Polyline(gl, { 221 | points, 222 | vertex, 223 | fragment, 224 | uniforms: { 225 | uColor: { value: new Color(color) }, 226 | uThickness: { value: thickness }, 227 | uOpacity: { value: 1.0 }, 228 | uTime: { value: 0.0 }, 229 | uEnableShaderEffect: { value: enableShaderEffect ? 1.0 : 0.0 }, 230 | uEffectAmplitude: { value: effectAmplitude }, 231 | uEnableFade: { value: enableFade ? 1.0 : 0.0 }, 232 | }, 233 | }); 234 | line.polyline.mesh.setParent(scene); 235 | lines.push(line); 236 | }); 237 | 238 | function resize() { 239 | if (!container) return; 240 | renderer.setSize(window.innerWidth, window.innerHeight); 241 | lines.forEach(line => line.polyline.resize()); 242 | } 243 | window.addEventListener('resize', resize); 244 | 245 | const tmp = new Vec3(); 246 | let frameId: number; 247 | let lastTime = performance.now(); 248 | function update() { 249 | frameId = requestAnimationFrame(update); 250 | const currentTime = performance.now(); 251 | const dt = currentTime - lastTime; 252 | lastTime = currentTime; 253 | 254 | lines.forEach(line => { 255 | tmp.copy(mouse) 256 | .add(line.mouseOffset) 257 | .sub(line.points[0]) 258 | .multiply(line.spring); 259 | line.mouseVelocity.add(tmp).multiply(line.friction); 260 | line.points[0].add(line.mouseVelocity); 261 | 262 | for (let i = 1; i < line.points.length; i++) { 263 | if (isFinite(maxAge) && maxAge > 0) { 264 | const segmentDelay = maxAge / (line.points.length - 1); 265 | const alpha = Math.min(1, (dt * speedMultiplier) / segmentDelay); 266 | line.points[i].lerp(line.points[i - 1], alpha); 267 | } else { 268 | line.points[i].lerp(line.points[i - 1], 0.9); 269 | } 270 | } 271 | if (line.polyline.mesh.program.uniforms.uTime) { 272 | line.polyline.mesh.program.uniforms.uTime.value = currentTime * 0.001; 273 | } 274 | line.polyline.updateGeometry(); 275 | }); 276 | 277 | renderer.render({ scene }); 278 | } 279 | update(); 280 | 281 | return () => { 282 | window.removeEventListener('mousemove', updateMouse); 283 | window.removeEventListener('touchmove', updateMouse); 284 | window.removeEventListener('resize', resize); 285 | cancelAnimationFrame(frameId); 286 | if (container.contains(gl.canvas)) { 287 | container.removeChild(gl.canvas); 288 | } 289 | }; 290 | }, [ 291 | isVisible, 292 | colors, 293 | baseSpring, 294 | baseFriction, 295 | baseThickness, 296 | offsetFactor, 297 | maxAge, 298 | pointCount, 299 | speedMultiplier, 300 | enableFade, 301 | enableShaderEffect, 302 | effectAmplitude, 303 | backgroundColor, 304 | ]); 305 | 306 | if (!isVisible) return null; 307 | 308 | return ( 309 |
320 | ); 321 | }; 322 | 323 | export default Ribbons; 324 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | span]:line-clamp-1", 23 | className 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | 48 | 49 | )) 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 64 | 65 | 66 | )) 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | 75 | 86 | 87 | 94 | {children} 95 | 96 | 97 | 98 | 99 | )) 100 | SelectContent.displayName = SelectPrimitive.Content.displayName 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )) 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef, 116 | React.ComponentPropsWithoutRef 117 | >(({ className, children, ...props }, ref) => ( 118 | 126 | 127 | 128 | 129 | 130 | 131 | {children} 132 | 133 | )) 134 | SelectItem.displayName = SelectPrimitive.Item.displayName 135 | 136 | const SelectSeparator = React.forwardRef< 137 | React.ElementRef, 138 | React.ComponentPropsWithoutRef 139 | >(({ className, ...props }, ref) => ( 140 | 145 | )) 146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 147 | 148 | export { 149 | Select, 150 | SelectGroup, 151 | SelectValue, 152 | SelectTrigger, 153 | SelectContent, 154 | SelectLabel, 155 | SelectItem, 156 | SelectSeparator, 157 | SelectScrollUpButton, 158 | SelectScrollDownButton, 159 | } 160 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/hooks/useKeyboardShortcut.tsx: -------------------------------------------------------------------------------- 1 | // src/app/hooks/useKeyboardShortcut.ts 2 | 'use client'; 3 | 4 | import { useEffect } from 'react'; 5 | 6 | export function useKeyboardShortcut(key: string, callback: () => void, useCtrlKey = false) { 7 | useEffect(() => { 8 | const handleKeyDown = (event: KeyboardEvent) => { 9 | // Check if the user is typing in an input field or textarea 10 | const isTyping = 11 | document.activeElement instanceof HTMLInputElement || 12 | document.activeElement instanceof HTMLTextAreaElement; 13 | 14 | // Only trigger shortcut if not typing in an input field 15 | if (!isTyping && event.key.toLowerCase() === key.toLowerCase()) { 16 | // If useCtrlKey is true, require Ctrl/Cmd key to be pressed 17 | if (useCtrlKey && !(event.ctrlKey || event.metaKey)) { 18 | return; 19 | } 20 | 21 | event.preventDefault(); 22 | callback(); 23 | } 24 | }; 25 | 26 | window.addEventListener('keydown', handleKeyDown); 27 | return () => window.removeEventListener('keydown', handleKeyDown); 28 | }, [key, callback, useCtrlKey]); 29 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/hooks/useTinybirdData.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { fetchLLMUsage, fetchGenericCounter, fetchLLMMessages } from '@/services/tinybird'; 3 | import { useTinybirdToken } from '@/providers/TinybirdProvider'; 4 | import { useState, useEffect } from 'react'; 5 | 6 | export function useLLMUsage(filters: Record = {}) { 7 | const { token, apiUrl } = useTinybirdToken(); 8 | 9 | return useQuery({ 10 | queryKey: ['llm-usage', filters], 11 | queryFn: () => fetchLLMUsage(token!, apiUrl!, filters), 12 | enabled: !!token && !!apiUrl 13 | }); 14 | } 15 | 16 | export function useGenericCounter(params: Record = {}) { 17 | const { token, apiUrl } = useTinybirdToken(); 18 | 19 | return useQuery({ 20 | queryKey: ['generic-counter', params], 21 | queryFn: () => fetchGenericCounter(token!, apiUrl!, params), 22 | enabled: !!token && !!apiUrl 23 | }); 24 | } 25 | 26 | export function useLLMMessages(filters: Record) { 27 | const { token, apiUrl } = useTinybirdToken(); 28 | 29 | return useQuery({ 30 | queryKey: ['llm-messages', filters], 31 | queryFn: () => fetchLLMMessages(token!, apiUrl!, filters), 32 | enabled: !!token && !!apiUrl 33 | }); 34 | } 35 | 36 | import { searchLLMMessagesByVector } from '@/services/tinybird'; 37 | 38 | export function useLLMVectorSearch( 39 | searchText: string | null, 40 | filters: Record 41 | ) { 42 | const { token, apiUrl } = useTinybirdToken(); 43 | const [embedding, setEmbedding] = useState(null); 44 | // const [isGeneratingEmbedding, setIsGeneratingEmbedding] = useState(false); 45 | 46 | // Generate embedding when search text changes 47 | useEffect(() => { 48 | async function generateEmbedding() { 49 | if (!searchText) { 50 | setEmbedding(null); 51 | return; 52 | } 53 | 54 | // setIsGeneratingEmbedding(true); 55 | try { 56 | const response = await fetch('/api/generate-embedding', { 57 | method: 'POST', 58 | headers: { 59 | 'Content-Type': 'application/json', 60 | }, 61 | body: JSON.stringify({ text: searchText }), 62 | }); 63 | 64 | if (!response.ok) { 65 | throw new Error('Failed to generate embedding'); 66 | } 67 | 68 | const data = await response.json(); 69 | setEmbedding(data.embedding); 70 | } catch (error) { 71 | console.error('Error generating embedding:', error); 72 | setEmbedding(null); 73 | } 74 | // finally { 75 | // setIsGeneratingEmbedding(false); 76 | // } 77 | } 78 | 79 | generateEmbedding(); 80 | }, [searchText]); 81 | 82 | return useQuery({ 83 | queryKey: ['llm-vector-search', searchText, embedding, filters], 84 | queryFn: () => searchLLMMessagesByVector(token!, apiUrl!, { 85 | ...filters, 86 | embedding: embedding || undefined, 87 | similarity_threshold: 0.6, // Adjust as needed 88 | }), 89 | enabled: !!token && !!apiUrl && !!embedding, 90 | }); 91 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/lib/dateUtils.ts: -------------------------------------------------------------------------------- 1 | // Helper function to format date 2 | export const formatDate = (date: Date): string => { 3 | const pad = (num: number): string => num.toString().padStart(2, '0'); 4 | return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; 5 | }; 6 | 7 | // Helper function to get last day of month 8 | const getLastDayOfMonth = (date: Date): Date => { 9 | return new Date(date.getFullYear(), date.getMonth() + 1, 0); 10 | }; 11 | 12 | // Helper function to get first day of quarter 13 | const getFirstDayOfQuarter = (date: Date): Date => { 14 | const quarter = Math.floor(date.getMonth() / 3); 15 | return new Date(date.getFullYear(), quarter * 3, 1); 16 | }; 17 | 18 | // Helper function to get last day of quarter 19 | const getLastDayOfQuarter = (date: Date): Date => { 20 | const quarter = Math.floor(date.getMonth() / 3); 21 | return new Date(date.getFullYear(), (quarter + 1) * 3, 0); 22 | }; 23 | 24 | // Map spelled-out numbers to digits 25 | const numberMap: { [key: string]: number } = { 26 | 'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5, 27 | 'six': 6, 'seven': 7, 'eight': 8, 'nine': 9, 'ten': 10, 28 | 'eleven': 11, 'twelve': 12, 'thirteen': 13, 'fourteen': 14, 'fifteen': 15, 29 | 'sixteen': 16, 'seventeen': 17, 'eighteen': 18, 'nineteen': 19, 'twenty': 20, 30 | 'thirty': 30, 'forty': 40, 'fifty': 50, 'sixty': 60, 'seventy': 70, 31 | 'eighty': 80, 'ninety': 90, 'hundred': 100 32 | }; 33 | 34 | // Extract number from string (e.g., "3 months" -> 3, "three months" -> 3) 35 | const extractNumber = (str: string): number => { 36 | // First try to find a numeric value 37 | const numericMatch = str.match(/(\d+)/); 38 | if (numericMatch) { 39 | return parseInt(numericMatch[1], 10); 40 | } 41 | 42 | // Then try to find spelled-out numbers 43 | const words = str.split(/\s+/); 44 | for (const word of words) { 45 | if (word in numberMap) { 46 | return numberMap[word]; 47 | } 48 | } 49 | 50 | // If no number found, default to 1 51 | return 1; 52 | }; 53 | 54 | export function extractDatesFromQuery(query: string): { start_date: string; end_date: string } { 55 | const now = new Date(); 56 | const queryLower = query.toLowerCase(); 57 | 58 | // Handle relative time expressions 59 | if (queryLower.includes('last')) { 60 | if (queryLower.includes('week')) { 61 | const weeks = extractNumber(queryLower); 62 | const startDate = new Date(now); 63 | startDate.setDate(now.getDate() - (weeks * 7)); 64 | return { start_date: formatDate(startDate), end_date: formatDate(now) }; 65 | } 66 | if (queryLower.includes('month')) { 67 | const months = extractNumber(queryLower); 68 | const startDate = new Date(now); 69 | startDate.setMonth(now.getMonth() - months); 70 | return { start_date: formatDate(startDate), end_date: formatDate(now) }; 71 | } 72 | if (queryLower.includes('year')) { 73 | const years = extractNumber(queryLower); 74 | const startDate = new Date(now); 75 | startDate.setFullYear(now.getFullYear() - years); 76 | return { start_date: formatDate(startDate), end_date: formatDate(now) }; 77 | } 78 | if (queryLower.includes('day')) { 79 | const days = extractNumber(queryLower); 80 | const startDate = new Date(now); 81 | startDate.setDate(now.getDate() - days); 82 | return { start_date: formatDate(startDate), end_date: formatDate(now) }; 83 | } 84 | } 85 | 86 | // Handle specific time ranges 87 | if (queryLower.includes('from') || queryLower.includes('between')) { 88 | const months = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']; 89 | const startMonth = months.findIndex(month => queryLower.includes(month)); 90 | const endMonth = months.findIndex(month => queryLower.includes(month), startMonth + 1); 91 | 92 | if (startMonth !== -1 && endMonth !== -1) { 93 | const startDate = new Date(now.getFullYear(), startMonth, 1); 94 | const endDate = new Date(now.getFullYear(), endMonth + 1, 0); 95 | return { start_date: formatDate(startDate), end_date: formatDate(endDate) }; 96 | } 97 | } 98 | 99 | // Handle quarters 100 | if (queryLower.includes('q1') || queryLower.includes('quarter 1')) { 101 | return { start_date: formatDate(new Date(now.getFullYear(), 0, 1)), end_date: formatDate(new Date(now.getFullYear(), 2, 31)) }; 102 | } 103 | if (queryLower.includes('q2') || queryLower.includes('quarter 2')) { 104 | return { start_date: formatDate(new Date(now.getFullYear(), 3, 1)), end_date: formatDate(new Date(now.getFullYear(), 5, 30)) }; 105 | } 106 | if (queryLower.includes('q3') || queryLower.includes('quarter 3')) { 107 | return { start_date: formatDate(new Date(now.getFullYear(), 6, 1)), end_date: formatDate(new Date(now.getFullYear(), 8, 30)) }; 108 | } 109 | if (queryLower.includes('q4') || queryLower.includes('quarter 4')) { 110 | return { start_date: formatDate(new Date(now.getFullYear(), 9, 1)), end_date: formatDate(new Date(now.getFullYear(), 11, 31)) }; 111 | } 112 | 113 | // Handle future predictions 114 | if (queryLower.includes('next')) { 115 | if (queryLower.includes('month')) { 116 | const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); 117 | return { start_date: formatDate(nextMonth), end_date: formatDate(getLastDayOfMonth(nextMonth)) }; 118 | } 119 | if (queryLower.includes('quarter')) { 120 | const nextQuarter = new Date(now.getFullYear(), now.getMonth() + 3, 1); 121 | return { start_date: formatDate(getFirstDayOfQuarter(nextQuarter)), end_date: formatDate(getLastDayOfQuarter(nextQuarter)) }; 122 | } 123 | if (queryLower.includes('year')) { 124 | const nextYear = new Date(now.getFullYear() + 1, 0, 1); 125 | return { start_date: formatDate(nextYear), end_date: formatDate(new Date(now.getFullYear() + 1, 11, 31)) }; 126 | } 127 | } 128 | 129 | // Default to last month if no specific time expression is found 130 | const startDate = new Date(now); 131 | startDate.setMonth(now.getMonth() - 1); 132 | return { start_date: formatDate(startDate), end_date: formatDate(now) }; 133 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/lib/dimensions.ts: -------------------------------------------------------------------------------- 1 | export const fetchAvailableDimensions = async (token: string | null, apiUrl: string | null) => { 2 | if (!token) { 3 | console.error('No Tinybird token available'); 4 | return null; 5 | } 6 | 7 | try { 8 | const url = `${apiUrl}/v0/pipes/llm_dimensions.json`; 9 | const response = await fetch(url, { 10 | headers: { 11 | Authorization: `Bearer ${token}`, 12 | }, 13 | }); 14 | 15 | if (!response.ok) { 16 | const error = await response.text(); 17 | console.error('Error fetching dimensions:', error); 18 | throw new Error('Network response was not ok'); 19 | } 20 | 21 | const data = await response.json(); 22 | console.log('Available dimensions:', data); 23 | return data; 24 | } catch (error) { 25 | console.error('Error fetching dimensions:', error); 26 | return null; 27 | } 28 | }; -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/lib/tinybird-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tinybird utility functions for handling API URLs and tokens 3 | */ 4 | 5 | /** 6 | * Converts a Tinybird host to its corresponding API URL 7 | * @param host The Tinybird host (e.g., 'gcp-europe-west2') 8 | * @returns The corresponding API URL 9 | */ 10 | export function getApiUrlFromHost(host: string): string { 11 | // Map of host patterns to API URLs 12 | const hostToApiUrl: Record = { 13 | 'gcp-europe-west2': 'https://api.europe-west2.gcp.tinybird.co', 14 | 'gcp-europe-west3': 'https://api.tinybird.co', 15 | 'eu_shared': 'https://api.tinybird.co', 16 | 'gcp-us-east4': 'https://api.us-east.tinybird.co', 17 | 'us_east': 'https://api.us-east.tinybird.co', 18 | 'northamerica-northeast2-gcp': 'https://api.northamerica-northeast2.gcp.tinybird.co', 19 | 'aws-eu-central-1': 'https://api.eu-central-1.aws.tinybird.co', 20 | 'aws-eu-west-1': 'https://api.eu-west-1.aws.tinybird.co', 21 | 'us-east-aws': 'https://api.us-east.aws.tinybird.co/', 22 | 'aws-us-west-2': 'https://api.us-west-2.aws.tinybird.co', 23 | }; 24 | 25 | // Check if the host matches any of the patterns 26 | for (const [pattern, apiUrl] of Object.entries(hostToApiUrl)) { 27 | if (host.includes(pattern)) { 28 | return apiUrl; 29 | } 30 | } 31 | 32 | // Default to the standard API URL if no match is found 33 | return 'https://api.tinybird.co'; 34 | } 35 | 36 | /** 37 | * Extracts the host from a JWT token 38 | * @param token The JWT token 39 | * @returns The host from the token or null if extraction fails 40 | */ 41 | export function extractHostFromToken(token: string): string | null { 42 | try { 43 | const base64Url = token.split('.')[1]; 44 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 45 | const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { 46 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 47 | }).join('')); 48 | const payload = JSON.parse(jsonPayload); 49 | return payload.host || null; 50 | } catch (e) { 51 | console.error('Error decoding token:', e); 52 | return null; 53 | } 54 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/lib/user-hash.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a user hash from the last 10 characters of the API key 3 | * This matches the hashApiKeyUser function in the search route 4 | */ 5 | export function generateUserHash(apiKey: string): string { 6 | if (!apiKey) return ''; 7 | 8 | // Get the last 10 characters of the API key 9 | const lastTenChars = apiKey.slice(-10); 10 | 11 | // Simple hash function (not cryptographically secure, but sufficient for this purpose) 12 | let hash = 0; 13 | for (let i = 0; i < lastTenChars.length; i++) { 14 | const char = lastTenChars.charCodeAt(i); 15 | hash = ((hash << 5) - hash) + char; 16 | hash = hash & hash; // Convert to 32bit integer 17 | } 18 | 19 | // Convert to a positive hex string and take first 8 characters 20 | const positiveHash = Math.abs(hash).toString(16); 21 | return `user_${positiveHash.substring(0, 8)}`; 22 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { clerkMiddleware } from "@clerk/nextjs/server" 2 | import { NextResponse } from "next/server" 3 | import * as jose from 'jose' 4 | import { getApiUrlFromHost, extractHostFromToken } from './lib/tinybird-utils' 5 | 6 | console.log('MIDDLEWARE FILE LOADED!!!') 7 | 8 | export default clerkMiddleware(async (auth, request) => { 9 | debugger; 10 | console.log('🔥🔥🔥 MIDDLEWARE EXECUTING 🔥🔥🔥') 11 | const authentication = await auth() 12 | const { userId, sessionId, sessionClaims, orgId, orgRole, orgPermissions } = authentication 13 | console.log('Auth details:', { userId, sessionId, sessionClaims, orgId, orgRole, orgPermissions }) 14 | 15 | // Get the token from the URL if present 16 | const url = new URL(request.url); 17 | const tokenParam = url.searchParams.get('token'); 18 | 19 | // If token param is present, use it directly 20 | if (tokenParam) { 21 | console.log('Using token from URL parameter'); 22 | const response = NextResponse.next(); 23 | response.headers.set('x-tinybird-token', tokenParam); 24 | 25 | // Extract host from token and convert to API URL 26 | const host = extractHostFromToken(tokenParam); 27 | if (host) { 28 | const apiUrl = getApiUrlFromHost(host); 29 | response.headers.set('x-org-name', apiUrl); 30 | } 31 | 32 | return response; 33 | } 34 | 35 | // If user is not authenticated, continue without modification 36 | if (!userId || !sessionId) { 37 | console.log('No user or session found') 38 | 39 | const response = NextResponse.next() 40 | response.headers.set('x-tinybird-token', process.env.NEXT_PUBLIC_TINYBIRD_API_KEY || '') 41 | return response 42 | } 43 | 44 | try { 45 | const orgName = orgPermissions?.[0]?.split(':').pop() || '' 46 | 47 | // Create Tinybird JWT 48 | const secret = new TextEncoder().encode(process.env.TINYBIRD_JWT_SECRET) 49 | const token = await new jose.SignJWT({ 50 | workspace_id: process.env.TINYBIRD_WORKSPACE_ID, 51 | name: `frontend_jwt_user_${userId}`, 52 | exp: Math.floor(Date.now() / 1000) + (60 * 15), // 15 minute expiration 53 | iat: Math.floor(Date.now() / 1000), 54 | scopes: [ 55 | { 56 | type: "PIPES:READ", 57 | resource: "generic_counter", 58 | fixed_params: { organization: orgName } 59 | }, 60 | { 61 | type: "PIPES:READ", 62 | resource: "llm_messages", 63 | fixed_params: { organization: orgName } 64 | }, 65 | { 66 | type: "PIPES:READ", 67 | resource: "llm_usage", 68 | fixed_params: { organization: orgName } 69 | }, 70 | { 71 | type: "PIPES:READ", 72 | resource: "llm_dimensions", 73 | fixed_params: { organization: orgName } 74 | } 75 | ], 76 | limits: { 77 | rps: 10 78 | } 79 | }) 80 | .setProtectedHeader({ alg: 'HS256' }) 81 | .sign(secret) 82 | debugger; 83 | console.log('Generated token:', token) 84 | 85 | // Clone the response and add token 86 | const response = NextResponse.next() 87 | response.headers.set('x-tinybird-token', token) 88 | response.headers.set('x-org-name', orgName) 89 | return response 90 | } catch (error) { 91 | console.error('Middleware error:', error) 92 | const response = NextResponse.next() 93 | response.headers.set('x-tinybird-token', process.env.NEXT_PUBLIC_TINYBIRD_API_KEY || '') 94 | return response 95 | } 96 | }) 97 | 98 | export const config = { 99 | matcher: [ 100 | // Skip Next.js internals and all static files, unless found in search params 101 | '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', 102 | // Always run for API routes 103 | '/(api|trpc)(.*)', 104 | ], 105 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/providers/TinybirdProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createContext, useContext, useState, ReactNode, useCallback, useMemo } from 'react'; 4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 5 | 6 | interface TinybirdContextType { 7 | token: string | null; 8 | orgName: string | null; 9 | apiUrl: string | null; 10 | setToken: (token: string) => void; 11 | setOrgName: (orgName: string) => void; 12 | setApiUrl: (apiUrl: string) => void; 13 | } 14 | 15 | const TinybirdContext = createContext(null); 16 | const queryClient = new QueryClient(); 17 | 18 | export function TinybirdProvider({ 19 | children, 20 | }: { 21 | children: ReactNode; 22 | }) { 23 | const [token, setTokenState] = useState(null); 24 | const [orgName, setOrgNameState] = useState(null); 25 | const [apiUrl, setApiUrlState] = useState(null); 26 | 27 | const setToken = useCallback((newToken: string) => { 28 | setTokenState(newToken); 29 | }, []); 30 | 31 | const setOrgName = useCallback((newOrgName: string) => { 32 | setOrgNameState(newOrgName); 33 | }, []); 34 | 35 | const setApiUrl = useCallback((newApiUrl: string) => { 36 | setApiUrlState(newApiUrl); 37 | }, []); 38 | 39 | const contextValue = useMemo(() => ({ 40 | token, 41 | orgName, 42 | apiUrl, 43 | setToken, 44 | setOrgName, 45 | setApiUrl 46 | }), [token, orgName, apiUrl, setToken, setOrgName, setApiUrl]); 47 | 48 | return ( 49 | 50 | 51 | {children} 52 | 53 | 54 | ); 55 | } 56 | 57 | export function useTinybirdToken() { 58 | const context = useContext(TinybirdContext); 59 | if (!context) { 60 | throw new Error('useTinybirdToken must be used within a TinybirdProvider'); 61 | } 62 | return context; 63 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/services/tinybird.ts: -------------------------------------------------------------------------------- 1 | export interface TinybirdParams { 2 | start_date?: string; 3 | end_date?: string; 4 | organization?: string; 5 | project?: string; 6 | column_name?: string; 7 | dimension?: string; 8 | } 9 | 10 | export interface LLMMessagesParams { 11 | start_date?: string; 12 | end_date?: string; 13 | organization?: string; 14 | project?: string; 15 | model?: string; 16 | provider?: string; 17 | environment?: string; 18 | user?: string; 19 | embedding?: number[]; 20 | similarity_threshold?: number; 21 | } 22 | 23 | export interface LLMVectorSearchParams { 24 | embedding?: number[]; 25 | similarity_threshold?: number; 26 | organization?: string; 27 | project?: string; 28 | model?: string; 29 | provider?: string; 30 | environment?: string; 31 | user?: string; 32 | } 33 | 34 | export async function fetchLLMUsage(token: string, apiUrl: string, filters: Record = {}) { 35 | console.log('Tinybird token in service:', token); 36 | console.log('Tinybird API URL in service:', apiUrl); 37 | 38 | if (!token) throw new Error('No Tinybird token available'); 39 | 40 | const searchParams = new URLSearchParams(); 41 | 42 | // Handle column_name separately as it's used for grouping 43 | if (filters.column_name) { 44 | searchParams.set('column_name', filters.column_name); 45 | } 46 | 47 | // Handle all other filter parameters 48 | const filterParams = ['model', 'provider', 'organization', 'project', 'environment', 'user']; 49 | filterParams.forEach(param => { 50 | if (filters[param as keyof Record]) { 51 | // Pass the comma-separated string directly - Tinybird will handle it as an array 52 | searchParams.set(param, filters[param as keyof Record]!); 53 | } 54 | }); 55 | 56 | // Handle date range 57 | if (filters.start_date) searchParams.set('start_date', filters.start_date); 58 | if (filters.end_date) searchParams.set('end_date', filters.end_date); 59 | 60 | const url = `${apiUrl}/v0/pipes/llm_usage.json?${searchParams.toString()}`; 61 | console.log('Tinybird request URL:', url); 62 | 63 | const response = await fetch(url, { 64 | headers: { 65 | Authorization: `Bearer ${token}`, 66 | }, 67 | }); 68 | 69 | console.log('Tinybird response status:', response.status); 70 | 71 | if (!response.ok) { 72 | const error = await response.text(); 73 | console.error('Tinybird error:', error); 74 | throw new Error('Network response was not ok'); 75 | } 76 | 77 | return response.json(); 78 | } 79 | 80 | export async function fetchGenericCounter(token: string, apiUrl: string, params: TinybirdParams) { 81 | if (!token) throw new Error('No Tinybird token available'); 82 | 83 | const searchParams = new URLSearchParams(); 84 | 85 | // Add all params to search params 86 | Object.entries(params).forEach(([key, value]) => { 87 | if (value) { 88 | // Pass the comma-separated string directly - Tinybird will handle it as an array 89 | searchParams.set(key, value.toString()); 90 | } 91 | }); 92 | 93 | const response = await fetch( 94 | `${apiUrl}/v0/pipes/generic_counter.json?${searchParams.toString()}`, 95 | { 96 | headers: { 97 | Authorization: `Bearer ${token}`, 98 | }, 99 | } 100 | ); 101 | 102 | if (!response.ok) { 103 | throw new Error('Network response was not ok'); 104 | } 105 | 106 | return response.json(); 107 | } 108 | 109 | export async function fetchLLMMessages(token: string, apiUrl: string, params: LLMMessagesParams = {}) { 110 | if (!token) throw new Error('No Tinybird token available'); 111 | 112 | // Determine if we should use POST (for embeddings) or GET (for regular queries) 113 | const hasEmbedding = params.embedding && params.embedding.length > 0; 114 | 115 | // Use vector search pipe if embedding is provided, otherwise use regular pipe 116 | const pipeName = 'llm_messages'; 117 | const baseUrl = `${apiUrl}/v0/pipes/${pipeName}.json`; 118 | 119 | let response; 120 | 121 | if (hasEmbedding) { 122 | // Use POST for embedding queries to avoid URL length limitations 123 | console.log('Using POST for Tinybird request with embeddings'); 124 | 125 | // Create request body with all parameters 126 | const requestBody: Record = {}; 127 | 128 | Object.entries(params).forEach(([key, value]) => { 129 | if (value !== undefined) { 130 | // For embedding, pass it directly as an array 131 | if (key === 'embedding') { 132 | requestBody[key] = value as number[]; 133 | } else { 134 | // Pass the comma-separated string directly - Tinybird will handle it as an array 135 | requestBody[key] = value.toString(); 136 | } 137 | } 138 | }); 139 | 140 | response = await fetch(baseUrl, { 141 | method: 'POST', 142 | headers: { 143 | 'Authorization': `Bearer ${token}`, 144 | 'Content-Type': 'application/json', 145 | }, 146 | body: JSON.stringify(requestBody), 147 | }); 148 | } else { 149 | // Use GET for regular queries 150 | const searchParams = new URLSearchParams(); 151 | 152 | // Add all params to search params 153 | Object.entries(params).forEach(([key, value]) => { 154 | if (value !== undefined) { 155 | // Pass the comma-separated string directly - Tinybird will handle it as an array 156 | searchParams.set(key, value.toString()); 157 | } 158 | }); 159 | 160 | const url = `${baseUrl}?${searchParams.toString()}`; 161 | console.log('Tinybird LLM Messages request URL:', url); 162 | 163 | response = await fetch(url, { 164 | headers: { 165 | 'Authorization': `Bearer ${token}`, 166 | }, 167 | }); 168 | } 169 | 170 | console.log('Tinybird LLM Messages response status:', response.status); 171 | 172 | if (!response.ok) { 173 | const error = await response.text(); 174 | console.error('Tinybird error:', error); 175 | throw new Error('Network response was not ok'); 176 | } 177 | 178 | return response.json(); 179 | } 180 | 181 | export async function searchLLMMessagesByVector( 182 | token: string, 183 | apiUrl: string, 184 | params: LLMVectorSearchParams = {} 185 | ) { 186 | if (!token) throw new Error('No Tinybird token available'); 187 | 188 | const searchParams = new URLSearchParams(); 189 | 190 | // Add embedding as a JSON string if provided 191 | if (params.embedding && params.embedding.length > 0) { 192 | searchParams.set('embedding', JSON.stringify(params.embedding)); 193 | } 194 | 195 | // Add similarity threshold if provided 196 | if (params.similarity_threshold) { 197 | searchParams.set('similarity_threshold', params.similarity_threshold.toString()); 198 | } 199 | 200 | // Add all other params 201 | Object.entries(params).forEach(([key, value]) => { 202 | if (value !== undefined && key !== 'embedding' && key !== 'similarity_threshold') { 203 | // Pass the comma-separated string directly - Tinybird will handle it as an array 204 | searchParams.set(key, value.toString()); 205 | } 206 | }); 207 | 208 | const url = `${apiUrl}/v0/pipes/llm_messages_vector_search.json?${searchParams.toString()}`; 209 | console.log('Tinybird Vector Search request URL:', url); 210 | 211 | const response = await fetch(url, { 212 | headers: { 213 | Authorization: `Bearer ${token}`, 214 | }, 215 | }); 216 | 217 | console.log('Tinybird Vector Search response status:', response.status); 218 | 219 | if (!response.ok) { 220 | const error = await response.text(); 221 | console.error('Tinybird error:', error); 222 | throw new Error('Network response was not ok'); 223 | } 224 | 225 | return response.json(); 226 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/src/stores/apiKeyStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | 4 | interface ApiKeyState { 5 | openaiKey: string | null; 6 | setOpenaiKey: (key: string) => void; 7 | clearOpenaiKey: () => void; 8 | } 9 | 10 | export const useApiKeyStore = create()( 11 | persist( 12 | (set) => ({ 13 | openaiKey: null, 14 | setOpenaiKey: (key) => set({ openaiKey: key }), 15 | clearOpenaiKey: () => set({ openaiKey: null }), 16 | }), 17 | { 18 | name: 'api-key-storage', 19 | } 20 | ) 21 | ); -------------------------------------------------------------------------------- /dashboard/ai-analytics/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './src/**/*.{js,ts,jsx,tsx,mdx}', 5 | './node_modules/@tremor/**/*.{js,ts,jsx,tsx}', 6 | ], 7 | darkMode: 'class', 8 | theme: { 9 | transparent: 'transparent', 10 | current: 'currentColor', 11 | extend: { 12 | colors: { 13 | // dark mode 14 | 'dark-tremor': { 15 | brand: { 16 | faint: '#0B1229', // custom 17 | muted: '#172554', // blue-950 18 | subtle: '#262626', 19 | DEFAULT: '#27F795', 20 | emphasis: '#353535', 21 | inverted: '#030712', // gray-950 22 | }, 23 | background: { 24 | muted: '#131A2B', // custom 25 | subtle: '#353535', 26 | DEFAULT: '#0A0A0A', 27 | emphasis: '#353535', 28 | }, 29 | border: { 30 | DEFAULT: '#1f2937', // gray-800 31 | }, 32 | ring: { 33 | DEFAULT: '#1f2937', // gray-800 34 | }, 35 | content: { 36 | subtle: '#4b5563', // gray-600 37 | DEFAULT: '#C6C6C6', // gray-500 38 | emphasis: '#e5e7eb', // gray-200 39 | strong: '#f9fafb', // gray-50 40 | inverted: '#000000', // black 41 | }, 42 | }, 43 | }, 44 | boxShadow: { 45 | // light 46 | 'tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 47 | 'tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 48 | 'tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 49 | // dark 50 | 'dark-tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 51 | 'dark-tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 52 | 'dark-tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 53 | }, 54 | borderRadius: { 55 | 'tremor-small': '0.375rem', 56 | 'tremor-default': '0.5rem', 57 | 'tremor-full': '9999px', 58 | }, 59 | fontSize: { 60 | 'tremor-label': ['0.75rem'], 61 | 'tremor-default': ['0.875rem', { 62 | lineHeight: '1.25rem', 63 | fontFamily: ['Roboto', 'sans-serif'] 64 | }], 65 | 'tremor-title': ['1.125rem', { lineHeight: '1.75rem' }], 66 | 'tremor-metric': ['24px', { 67 | lineHeight: '20px', 68 | fontFamily: ['Roboto Mono', 'monospace'], 69 | fontWeight: '400', 70 | fontStyle: 'normal' 71 | }], 72 | 'tremor-metric-xl': ['48px', { 73 | lineHeight: '48px', 74 | fontFamily: ['Roboto Mono', 'monospace'], 75 | fontWeight: '400', 76 | fontStyle: 'normal' 77 | }], 78 | 'tremor-tab': ['14px', { lineHeight: '20px' }], // custom tab font size 79 | }, 80 | // Add Tremor chart styles 81 | tremor: { 82 | chart: { 83 | axis: { 84 | label: { 85 | color: '#C6C6C6', 86 | }, 87 | }, 88 | }, 89 | }, 90 | }, 91 | }, 92 | safelist: [ 93 | { 94 | pattern: 95 | /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 96 | variants: ['hover', 'ui-selected'], 97 | }, 98 | { 99 | pattern: 100 | /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 101 | variants: ['hover', 'ui-selected'], 102 | }, 103 | { 104 | pattern: 105 | /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 106 | variants: ['hover', 'ui-selected'], 107 | }, 108 | { 109 | pattern: 110 | /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 111 | }, 112 | { 113 | pattern: 114 | /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 115 | }, 116 | { 117 | pattern: 118 | /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 119 | }, 120 | // Add custom colors to safelist using flatMap 121 | ...['#27F795', '#27F795CC', '#27F79599', '#27F79566', '#27F79533'].flatMap((customColor) => [ 122 | `bg-[${customColor}]`, 123 | `border-[${customColor}]`, 124 | `hover:bg-[${customColor}]`, 125 | `hover:border-[${customColor}]`, 126 | `hover:text-[${customColor}]`, 127 | `fill-[${customColor}]`, 128 | `ring-[${customColor}]`, 129 | `stroke-[${customColor}]`, 130 | `text-[${customColor}]`, 131 | `ui-selected:bg-[${customColor}]`, 132 | `ui-selected:border-[${customColor}]`, 133 | `ui-selected:text-[${customColor}]`, 134 | ]), 135 | ], 136 | plugins: [], 137 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/tailwind.config.js_deleteme: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './src/**/*.{js,ts,jsx,tsx,mdx}', 5 | './node_modules/@tremor/**/*.{js,ts,jsx,tsx}', 6 | ], 7 | darkMode: ['class', 'class'], 8 | theme: { 9 | transparent: 'transparent', 10 | current: 'currentColor', 11 | extend: { 12 | colors: { 13 | tremor: { 14 | brand: { 15 | faint: '#eff6ff', 16 | muted: '#bfdbfe', 17 | subtle: '#60a5fa', 18 | DEFAULT: '#3b82f6', 19 | emphasis: '#1d4ed8', 20 | inverted: '#ffffff' 21 | }, 22 | background: { 23 | muted: '#f9fafb', 24 | subtle: '#f3f4f6', 25 | DEFAULT: '#ffffff', 26 | emphasis: '#374151' 27 | }, 28 | border: { 29 | DEFAULT: '#e5e7eb' 30 | }, 31 | ring: { 32 | DEFAULT: '#e5e7eb' 33 | }, 34 | content: { 35 | subtle: '#9ca3af', 36 | DEFAULT: '#6b7280', 37 | emphasis: '#374151', 38 | strong: '#111827', 39 | inverted: '#ffffff' 40 | } 41 | }, 42 | 'dark-tremor': { 43 | brand: { 44 | faint: '#0B1229', 45 | muted: '#172554', 46 | subtle: '#1e40af', 47 | DEFAULT: '#3b82f6', 48 | emphasis: '#60a5fa', 49 | inverted: '#030712' 50 | }, 51 | background: { 52 | muted: '#131A2B', 53 | subtle: '#1f2937', 54 | DEFAULT: '#111827', 55 | emphasis: '#d1d5db' 56 | }, 57 | border: { 58 | DEFAULT: '#1f2937' 59 | }, 60 | ring: { 61 | DEFAULT: '#1f2937' 62 | }, 63 | content: { 64 | subtle: '#4b5563', 65 | DEFAULT: '#6b7280', 66 | emphasis: '#e5e7eb', 67 | strong: '#f9fafb', 68 | inverted: '#000000' 69 | } 70 | }, 71 | background: 'hsl(var(--background))', 72 | foreground: 'hsl(var(--foreground))', 73 | card: { 74 | DEFAULT: 'hsl(var(--card))', 75 | foreground: 'hsl(var(--card-foreground))' 76 | }, 77 | popover: { 78 | DEFAULT: 'hsl(var(--popover))', 79 | foreground: 'hsl(var(--popover-foreground))' 80 | }, 81 | primary: { 82 | DEFAULT: 'hsl(var(--primary))', 83 | foreground: 'hsl(var(--primary-foreground))' 84 | }, 85 | secondary: { 86 | DEFAULT: 'hsl(var(--secondary))', 87 | foreground: 'hsl(var(--secondary-foreground))' 88 | }, 89 | muted: { 90 | DEFAULT: 'hsl(var(--muted))', 91 | foreground: 'hsl(var(--muted-foreground))' 92 | }, 93 | accent: { 94 | DEFAULT: 'hsl(var(--accent))', 95 | foreground: 'hsl(var(--accent-foreground))' 96 | }, 97 | destructive: { 98 | DEFAULT: 'hsl(var(--destructive))', 99 | foreground: 'hsl(var(--destructive-foreground))' 100 | }, 101 | border: 'hsl(var(--border))', 102 | input: 'hsl(var(--input))', 103 | ring: 'hsl(var(--ring))', 104 | chart: { 105 | '1': 'hsl(var(--chart-1))', 106 | '2': 'hsl(var(--chart-2))', 107 | '3': 'hsl(var(--chart-3))', 108 | '4': 'hsl(var(--chart-4))', 109 | '5': 'hsl(var(--chart-5))' 110 | } 111 | }, 112 | boxShadow: { 113 | 'tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 114 | 'tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 115 | 'tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 116 | 'dark-tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 117 | 'dark-tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 118 | 'dark-tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)' 119 | }, 120 | borderRadius: { 121 | 'tremor-small': '0.375rem', 122 | 'tremor-default': '0.5rem', 123 | 'tremor-full': '9999px', 124 | lg: 'var(--radius)', 125 | md: 'calc(var(--radius) - 2px)', 126 | sm: 'calc(var(--radius) - 4px)' 127 | }, 128 | fontSize: { 129 | 'tremor-label': [ 130 | '0.75rem' 131 | ], 132 | 'tremor-default': [ 133 | '0.875rem', 134 | { 135 | lineHeight: '1.25rem' 136 | } 137 | ], 138 | 'tremor-title': [ 139 | '1.125rem', 140 | { 141 | lineHeight: '1.75rem' 142 | } 143 | ], 144 | 'tremor-metric': [ 145 | '1.875rem', 146 | { 147 | lineHeight: '2.25rem' 148 | } 149 | ] 150 | } 151 | } 152 | }, 153 | safelist: [ 154 | { 155 | pattern: 156 | /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 157 | variants: ['hover', 'ui-selected'], 158 | }, 159 | { 160 | pattern: 161 | /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 162 | variants: ['hover', 'ui-selected'], 163 | }, 164 | { 165 | pattern: 166 | /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 167 | variants: ['hover', 'ui-selected'], 168 | }, 169 | { 170 | pattern: 171 | /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 172 | }, 173 | { 174 | pattern: 175 | /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 176 | }, 177 | { 178 | pattern: 179 | /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 180 | }, 181 | ], 182 | plugins: [require("tailwindcss-animate")], 183 | } -------------------------------------------------------------------------------- /dashboard/ai-analytics/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /tinybird/.gitignore: -------------------------------------------------------------------------------- 1 | .tinyb 2 | -------------------------------------------------------------------------------- /tinybird/README.md: -------------------------------------------------------------------------------- 1 | This is a [Tinybird project](https://www.tinybird.co/docs/forward) for the AI analytics template. 2 | 3 | It has a single table `llm_events` that stores LLM events with usage metrics, costs, and metadata. 4 | 5 | Endpoints: 6 | 7 | - `generic_counter` shows cost by several dimensions. 8 | - `llm_usage` is used to build time series charts. 9 | - `llm_messages` is used to build the vector search. 10 | 11 | ## Local development 12 | 13 | Start Tinybird locally: 14 | 15 | ``` 16 | curl https://tinybird.co | sh 17 | cd tinybird 18 | tb local start 19 | tb login 20 | tb dev 21 | ``` 22 | 23 | Generate mock data: 24 | 25 | ``` 26 | cd tinybird/mock 27 | npm install 28 | npm run generate -- --start-date 2025-02-01 --end-date 2025-03-31 --events-per-day 100 --output ../fixtures/llm_events.ndjson 29 | ``` 30 | 31 | ## Deploy to cloud 32 | 33 | Use the [CI/CD GitHub actions](https://github.com/tinybirdco/ai-analytics-template/tree/main/.github/workflows) in this repository to deploy to Tinybird or the Tinybird CLI: 34 | 35 | ``` 36 | tb --cloud deploy 37 | ``` 38 | -------------------------------------------------------------------------------- /tinybird/datasources/llm_events.datasource: -------------------------------------------------------------------------------- 1 | 2 | DESCRIPTION > 3 | Store LLM events with usage metrics, costs, and metadata 4 | 5 | SCHEMA > 6 | `timestamp` DateTime `json:$.start_time`, 7 | `organization` String `json:$.proxy_metadata.organization` DEFAULT '', 8 | `project` String `json:$.proxy_metadata.project` DEFAULT '', 9 | `environment` String `json:$.proxy_metadata.environment` DEFAULT '', 10 | `user` String `json:$.user` DEFAULT 'unknown', 11 | `chat_id` String `json:$.proxy_metadata.chat_id` DEFAULT '', 12 | `message_id` String `json:$.message_id`, 13 | `model` LowCardinality(String) `json:$.model` DEFAULT 'unknown', 14 | `prompt_tokens` UInt16 `json:$.response.usage.prompt_tokens` DEFAULT 0, 15 | `completion_tokens` UInt16 `json:$.response.usage.completion_tokens` DEFAULT 0, 16 | `total_tokens` UInt16 `json:$.response.usage.total_tokens` DEFAULT 0, 17 | `response_time` Float32 `json:$.standard_logging_object_response_time` DEFAULT 0, 18 | `duration` Float32 `json:$.duration` DEFAULT 0, 19 | `cost` Float32 `json:$.cost` DEFAULT 0, 20 | `exception` String `json:$.exception` DEFAULT '', 21 | `traceback` String `json:$.traceback` DEFAULT '', 22 | `response_status` LowCardinality(String) `json:$.standard_logging_object_status` DEFAULT 'unknown', 23 | 24 | `messages` Array(Map(String, String)) `json:$.messages[:]` DEFAULT [], 25 | `response_choices` Array(String) `json:$.response.choices[:]` DEFAULT [], 26 | `proxy_metadata` String `json:$.proxy_metadata` DEFAULT '', 27 | `provider` LowCardinality(String) `json:$.provider` DEFAULT 'unknown', 28 | 29 | `llm_api_duration_ms` Float32 `json:$.llm_api_duration_ms` DEFAULT 0, 30 | `end_time` DateTime `json:$.end_time`, 31 | `id` String `json:$.id` DEFAULT '', 32 | `stream` Bool `json:$.stream` DEFAULT false, 33 | `call_type` LowCardinality(String) `json:$.call_type` DEFAULT 'unknown', 34 | `api_key` String `json:$.api_key` DEFAULT '', 35 | `log_event_type` LowCardinality(String) `json:$.log_event_type` DEFAULT 'unknown', 36 | `cache_hit` Bool `json:$.cache_hit` DEFAULT false, 37 | `response` String `json:$.response` DEFAULT '', 38 | `response_id` String `json:$.response.id`, 39 | `response_object` String `json:$.response.object` DEFAULT 'unknown', 40 | 41 | `embedding` Array(Float32) `json:$.embedding[:]` DEFAULT [] 42 | 43 | ENGINE "MergeTree" 44 | ENGINE_PARTITION_KEY "toYYYYMM(timestamp)" 45 | ENGINE_SORTING_KEY "timestamp, organization, project, environment, user, chat_id" 46 | ENGINE_PRIMARY_KEY "timestamp, organization, project" 47 | 48 | FORWARD_QUERY > 49 | SELECT timestamp, organization, project, environment, user, chat_id, message_id, model, prompt_tokens, completion_tokens, total_tokens, response_time, duration, cost, exception, traceback, response_status, messages, response_choices, proxy_metadata, provider, llm_api_duration_ms, end_time, id, CAST(stream, 'Bool') AS stream, call_type, api_key, log_event_type, CAST(cache_hit, 'Bool') AS cache_hit, response, response_id, response_object, defaultValueOfTypeName('Array(Float32)') AS embedding -------------------------------------------------------------------------------- /tinybird/endpoints/generic_counter.pipe: -------------------------------------------------------------------------------- 1 | TOKEN read_pipes READ 2 | 3 | NODE count_attributes 4 | SQL > 5 | % 6 | {% if not defined(dimension) %} 7 | {{ error('dimension (String) query param is required') }} 8 | {% end %} 9 | {% if defined(dimension) and dimension not in ['organization', 'project', 'environment', 'provider', 'user', 'model'] %} 10 | {{ error('dimension (String) query param must be one of the following: organization, project, environment, provider, user, model') }} 11 | {% end %} 12 | SELECT 13 | toString({{column(dimension, 'organization')}}) as category, 14 | count() as count, 15 | sum(cost) as total_cost 16 | FROM llm_events 17 | WHERE 1=1 18 | {% if defined(start_date) and defined(end_date) %} 19 | AND timestamp >= {{DateTime(start_date, '2025-01-01 00:00:00')}} 20 | AND timestamp <= {{DateTime(end_date, '2025-12-31 23:59:59')}} 21 | {% end %} 22 | {% if defined(organization) and organization != [''] %} 23 | AND organization in {{Array(organization)}} 24 | {% end %} 25 | {% if defined(project) and project != [''] %} 26 | AND project in {{Array(project)}} 27 | {% end %} 28 | {% if defined(environment) and environment != [''] %} 29 | AND environment in {{Array(environment)}} 30 | {% end %} 31 | {% if defined(provider) and provider != [''] %} 32 | AND provider in {{Array(provider)}} 33 | {% end %} 34 | {% if defined(user) and user != [''] %} 35 | AND user in {{Array(user)}} 36 | {% end %} 37 | {% if defined(model) and model != [''] %} 38 | AND model in {{Array(model)}} 39 | {% end %} 40 | GROUP BY {{column(dimension, 'organization')}} 41 | ORDER BY total_cost DESC, {{column(dimension, 'organization')}} 42 | 43 | TYPE endpoint -------------------------------------------------------------------------------- /tinybird/endpoints/llm_dimensions.pipe: -------------------------------------------------------------------------------- 1 | TOKEN read_pipes READ 2 | 3 | NODE llm_dimensions_node 4 | SQL > 5 | % 6 | SELECT 7 | groupUniqArray(organization) as organizations, 8 | groupUniqArray(project) as project, 9 | groupUniqArray(environment) as environment, 10 | groupUniqArray(model) as model, 11 | groupUniqArray(provider) as provider 12 | FROM 13 | ( 14 | SELECT organization, project, environment, model, provider 15 | FROM 16 | llm_events WHERE timestamp > now() - interval '1 month' 17 | {% if defined(organization) and organization != [''] %} 18 | AND organization IN {{Array(organization)}} 19 | {% end %} 20 | ) 21 | 22 | 23 | TYPE endpoint -------------------------------------------------------------------------------- /tinybird/endpoints/llm_messages.pipe: -------------------------------------------------------------------------------- 1 | TOKEN "read_pipes" READ 2 | 3 | DESCRIPTION > 4 | Get detailed LLM messages with filters 5 | 6 | NODE llm_messages_node 7 | SQL > 8 | % 9 | {% if defined(embedding) %} 10 | with cosineDistance(embedding, {{ Array(embedding, 'Float32') }}) as _similarity 11 | {% end %} 12 | SELECT 13 | timestamp, 14 | organization, 15 | project, 16 | environment, 17 | user, 18 | chat_id, 19 | message_id, 20 | model, 21 | provider, 22 | prompt_tokens, 23 | completion_tokens, 24 | total_tokens, 25 | duration, 26 | cost, 27 | response_status, 28 | exception 29 | {% if defined(embedding) %} 30 | , 1 - _similarity as similarity 31 | {% else %} 32 | , 0 as similarity 33 | {% end %} 34 | {% if defined(chat_id) %} 35 | , messages, response_choices 36 | {% else %} 37 | , [] as messages, [] as response_choices 38 | {% end %} 39 | FROM (select * from llm_events where organization != 'your-org') 40 | WHERE 1 41 | {% if defined(organization) and organization != [''] %} 42 | AND organization IN {{Array(organization)}} 43 | {% end %} 44 | {% if defined(project) and project != [''] %} 45 | AND project IN {{Array(project)}} 46 | {% end %} 47 | {% if defined(environment) and environment != [''] %} 48 | AND environment IN {{Array(environment)}} 49 | {% end %} 50 | {% if defined(user) and user != [''] %} 51 | AND user IN {{Array(user)}} 52 | {% end %} 53 | {% if defined(model) and model != [''] %} 54 | AND model IN {{Array(model)}} 55 | {% end %} 56 | {% if defined(provider) and provider != [''] %} 57 | AND provider IN {{Array(provider)}} 58 | {% end %} 59 | {% if defined(chat_id) and chat_id != [''] %} 60 | AND chat_id IN {{Array(chat_id)}} 61 | {% end %} 62 | {% if defined(message_id) and message_id != [''] %} 63 | AND message_id IN {{Array(message_id)}} 64 | {% end %} 65 | {% if defined(start_date) %} 66 | AND timestamp >= {{DateTime(start_date)}} 67 | {% end %} 68 | {% if defined(end_date) %} 69 | AND timestamp < {{DateTime(end_date)}} 70 | {% end %} 71 | {% if defined(embedding) %} 72 | AND length(embedding) > 0 and _similarity <= {{ Float64(similarity_threshold, 0.5) }} 73 | and response_status != 'error' 74 | {% end %} 75 | {% if defined(embedding) %} 76 | ORDER BY similarity DESC 77 | {% else %} 78 | ORDER BY timestamp DESC 79 | {% end %} 80 | LIMIT {{Int32(limit, 200)}} 81 | 82 | TYPE endpoint 83 | -------------------------------------------------------------------------------- /tinybird/endpoints/llm_usage.pipe: -------------------------------------------------------------------------------- 1 | TOKEN "read_pipes" READ 2 | 3 | NODE endpoint 4 | SQL > 5 | % 6 | {% if defined(column_name) and column_name not in ['model', 'provider', 'organization', 'project', 'environment', 'user'] %} 7 | {{ error('column_name (String) query param must be one of the following: model, provider, organization, project, environment, user') }} 8 | {% end %} 9 | SELECT 10 | toDate(timestamp) as date, 11 | {% if defined(column_name) %} 12 | toString({{column(column_name, 'model')}}) as category, 13 | {% end %} 14 | count() as total_requests, 15 | countIf(exception != '') as total_errors, 16 | sum(total_tokens) as total_tokens, 17 | sum(completion_tokens) as total_completion_tokens, 18 | sum(prompt_tokens) as total_prompt_tokens, 19 | sum(cost) as total_cost, 20 | avg(duration) as avg_duration, 21 | avg(response_time) as avg_response_time 22 | FROM llm_events 23 | WHERE 1 24 | {% if defined(organization) and organization != [''] %} 25 | AND organization IN {{Array(organization)}} 26 | {% end %} 27 | {% if defined(project) and project != [''] %} 28 | AND project IN {{Array(project)}} 29 | {% end %} 30 | {% if defined(environment) and environment != [''] %} 31 | AND environment IN {{Array(environment)}} 32 | {% end %} 33 | {% if defined(provider) and provider != [''] %} 34 | AND provider IN {{Array(provider)}} 35 | {% end %} 36 | {% if defined(user) and user != [''] %} 37 | AND user IN {{Array(user)}} 38 | {% end %} 39 | {% if defined(model) and model != [''] %} 40 | AND model IN {{Array(model)}} 41 | {% end %} 42 | {% if defined(start_date) %} 43 | AND timestamp >= {{DateTime(start_date)}} 44 | {% else %} 45 | AND timestamp >= now() - interval 7 day 46 | {% end %} 47 | {% if defined(end_date) %} 48 | AND timestamp < {{DateTime(end_date)}} 49 | {% else %} 50 | AND timestamp < now() 51 | {% end %} 52 | GROUP BY 53 | {% if defined(column_name) %} 54 | category, 55 | {% end %} 56 | date 57 | order by total_cost desc 58 | 59 | TYPE endpoint 60 | -------------------------------------------------------------------------------- /tinybird/fixtures/llm_events.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | now() - rand() % 86400 as start_time, 3 | concat('org_', toString(1 + rand() % 5)) as `proxy_metadata.organization`, 4 | concat('project_', toString(1 + rand() % 3)) as `proxy_metadata.project`, 5 | concat('env_', toString(1 + rand() % 3)) as `proxy_metadata.environment`, 6 | concat('user_', toString(1 + rand() % 100)) as user, 7 | concat('chat_', toString(1 + rand() % 1000)) as `proxy_metadata.chat_id`, 8 | concat('msg_', toString(1 + rand() % 10000)) as message_id, 9 | ['gpt-4', 'gpt-3.5-turbo'][1 + rand() % 2] as model, 10 | 50 + rand() % 500 as `response.usage.prompt_tokens`, 11 | 100 + rand() % 1000 as `response.usage.completion_tokens`, 12 | 150 + rand() % 1500 as `response.usage.total_tokens`, 13 | rand() / 10 as standard_logging_object_response_time, 14 | rand() as duration, 15 | round(rand() / 100, 3) as cost, 16 | '' as exception, 17 | '' as traceback, 18 | ['success', 'error'][1 + rand() % 2] as standard_logging_object_status, 19 | [] as messages, 20 | [] as `response.choices`, 21 | '{}' as proxy_metadata, 22 | ['openai', 'anthropic'][1 + rand() % 2] as provider, 23 | rand() * 1000 as llm_api_duration_ms, 24 | now() as end_time, 25 | concat('id_', toString(rand() % 10000)) as id, 26 | rand() % 2 = 1 as stream, 27 | ['chat', 'completion'][1 + rand() % 2] as call_type, 28 | concat('sk-', lower(hex(randomString(8)))) as api_key, 29 | 'llm' as log_event_type, 30 | rand() % 2 = 1 as cache_hit, 31 | '{"content": "sample response"}' as response, 32 | concat('res_', toString(rand() % 10000)) as `response.id`, 33 | 'chat.completion' as `response.object` 34 | FROM numbers(10) -------------------------------------------------------------------------------- /tinybird/mock/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "llm-events-generator", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "description": "Generate mock LLM events data for Tinybird", 6 | "main": "generate-llm-events.js", 7 | "scripts": { 8 | "generate": "node generate-llm-events.js" 9 | }, 10 | "dependencies": { 11 | "@faker-js/faker": "^9.5.1", 12 | "@xenova/transformers": "^2.17.2", 13 | "commander": "^11.0.0", 14 | "uuid": "^11.1.0" 15 | }, 16 | "bin": { 17 | "generate-llm-events": "./generate-llm-events.js" 18 | } 19 | } 20 | --------------------------------------------------------------------------------