├── .env_example ├── .gitignore ├── DIST.md ├── LICENSE ├── README.md ├── client ├── App.css ├── App.tsx ├── assets │ ├── dbpill.png │ └── dbpill_diagram.svg ├── components │ ├── About.tsx │ ├── Configs.tsx │ ├── QueryDetailsBar.tsx │ ├── QueryList.tsx │ ├── QuerySuggestions.tsx │ └── SuggestionBox.tsx ├── context │ └── AppContext.tsx ├── main.tsx ├── styles │ └── Styled.tsx └── utils │ ├── HttpApi.ts │ ├── formatNumber.ts │ └── sqlHighlighter.tsx ├── credentials ├── index.ts ├── proxy.crt └── proxy.key ├── index.html ├── landing ├── .gitignore ├── client │ ├── dbpill_diagram.svg │ ├── dbpill_diagram_old.svg │ ├── dbpill_web.png │ ├── downloads.html │ ├── index.html │ └── preview.png ├── email.ts ├── package.json ├── run.ts └── tsconfig.json ├── make_all_executables.sh ├── make_executable.sh ├── node-sea.ts ├── package.json ├── proxy-standalone.ts ├── run.ts ├── run_executable.ts ├── sea-config.json ├── sea.entitlements ├── server ├── apis │ ├── http.ts │ └── sockets.ts ├── args.ts ├── config_manager.ts ├── database_helper.ts ├── llm.ts ├── main_props.ts ├── prompt_generator.ts ├── proxy.ts ├── query_analyzer.ts ├── query_logger.ts └── ssr.tsx ├── shared ├── main_props.ts └── types.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite-env.d.ts └── vite.config.ts /.env_example: -------------------------------------------------------------------------------- 1 | AWS_ACCESS_KEY_ID= 2 | AWS_SECRET_ACCESS_KEY= 3 | AWS_DEFAULT_REGION=us-west-x 4 | S3_BUCKET=dbpill-releases 5 | 6 | MAC_CODESIGN_IDENTITY="Developer ID Application: Xyz Abc (1233454ASD)" 7 | MAC_NOTARIZE_PROFILE=dbpill # created via `xcrun notarytool store-credentials` 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-esm 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | bun.lockb 28 | **/credentials/*.json 29 | 30 | queries.db 31 | build 32 | server.bundle.* 33 | 34 | dbpill.sqlite.db 35 | purchases.db 36 | purchases.db-* 37 | 38 | package-lock.json 39 | bun.lock 40 | 41 | dbpill 42 | sea-prep.blob 43 | cache 44 | 45 | keys/ 46 | .env 47 | -------------------------------------------------------------------------------- /DIST.md: -------------------------------------------------------------------------------- 1 | # murat notes 2 | 3 | npx tsx run.ts --mode development --port 3000 --db=postgresql://cashorbit@localhost:5432/cashorbit 4 | 5 | 6 | # Distribution & Development Guide 7 | 8 | This document explains two workflows: 9 | 10 | 1. Local development with plain `node`/TypeScript. 11 | 2. Producing a **single-file executable** using Node SEA for shipping to end-users. 12 | 13 | --- 14 | ## 1 · Local development 15 | 16 | 1. **Install deps** (once): 17 | ```bash 18 | npm install 19 | ``` 20 | 21 | 2. **Start the server in development mode** (Vite middleware + HMR): 22 | ```bash 23 | npx tsx run.ts --mode development --port 3000 # or: npm run dev 24 | ``` 25 | 26 | * `run.ts` boots Vite in middleware mode, so it automatically handles HMR 27 | and transforms your React/Vue/Svelte pages on the fly—no manual `vite build` step required. 28 | * Requests to `/client/**` are proxied to Vite; API/Socket.IO routes work exactly as in production. 29 | 30 | Open in your browser. 31 | 32 | --- 33 | ## 2 · Shipping a single executable (Node SEA) 34 | 35 | SEA lets you bundle **one CommonJS file + static assets** into a copy of 36 | `node`, creating an app that runs on machines **without Node installed**. 37 | 38 | ### 2.1 Prerequisites (once) 39 | ```bash 40 | npm install --save-dev esbuild postject 41 | ``` 42 | 43 | ### 2.2 Build steps 44 | 45 | > All commands assume the project root (`dbpill`) as CWD. 46 | 47 | 1. **Build the client** 48 | ```bash 49 | npm run build # runs Vite → dist/** 50 | ``` 51 | 52 | 2. **Bundle & transpile the server** (TypeScript → CJS) 53 | ```bash 54 | npx esbuild run_executable.ts \ 55 | --bundle --platform=node --format=cjs \ 56 | --outfile=server.bundle.cjs 57 | ``` 58 | 59 | 3. **Create `sea-config.json`** 60 | ```jsonc 61 | { 62 | "main": "./server.bundle.cjs", 63 | "disableExperimentalSEAWarning": true, 64 | "output": "sea-prep.blob", 65 | "assets": { 66 | "dist/index.html": "./dist/index.html", 67 | "dist/index.js.txt": "./dist/index.js.txt", 68 | "dist/assets/index.css": "./dist/assets/index.css", 69 | "dbpill.sqlite.db": "./dbpill.sqlite.db" 70 | } 71 | } 72 | ``` 73 | 74 | 4. **Generate the SEA blob** 75 | ```bash 76 | node --experimental-sea-config sea-config.json 77 | ``` 78 | 79 | 5. **Create a copy of the Node binary & inject the blob** 80 | 81 | ```bash 82 | # macOS example – adjust flags for Linux/Windows 83 | 84 | cp $(command -v node) dbpill # final executable name 85 | codesign --remove-signature dbpill # mac only 86 | 87 | npx postject dbpill NODE_SEA_BLOB sea-prep.blob \ 88 | --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \ 89 | --macho-segment-name NODE_SEA # omit on Linux 90 | 91 | codesign --sign - dbpill # re-sign on mac 92 | ``` 93 | 94 | ### 2.3 Run the binary 95 | ```bash 96 | ./dbpill --port 3000 97 | ``` 98 | 99 | ### 2.4 What's inside vs. outside 100 | * **Inside**: bundled server CJS, `dist/**` assets, `dbpill.sqlite.db`. 101 | * The executable is now completely self-contained using Node.js 24's built-in SQLite. 102 | 103 | --- 104 | ## 3 Convenient npm scripts 105 | Add these to `package.json` if you like: 106 | ```jsonc 107 | { 108 | "scripts": { 109 | "dev": "tsx run.ts --mode development --port 3000", 110 | "build:client": "vite build", 111 | 112 | "sea:bundle": "esbuild run_executable.ts --bundle --platform=node --format=cjs --outfile=server.bundle.cjs", 113 | "sea:prep": "node --experimental-sea-config sea-config.json", 114 | "sea:build": "npm run build:client && npm run sea:bundle && npm run sea:prep", 115 | "sea:inject": "postject dbpill NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 --macho-segment-name NODE_SEA" 116 | } 117 | } 118 | ``` 119 | 120 | Then: 121 | ```bash 122 | npm run dev # dev server with live reload 123 | npm run sea:build # produce sea-prep.blob 124 | npm run sea:inject # inject into ./dbpill 125 | ``` 126 | 127 | Happy hacking & shipping! 🚀 128 | 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Murat Ayfer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | Commercial users are encouraged to support development via sponsorship or donation. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dbpill 2 | 3 | This is a PostgreSQL proxy that intercepts all queries & provides a web interface to profile them, sort them, auto-suggest indexes to improve performance, and immediately apply changes & measure improvements, with instant rollback when performance isn't improved. See https://dbpill.com for more info 4 | 5 | # Quick run 6 | 7 | ``` 8 | npm install 9 | npm run dev postgresql://user:pass@host:5432/dbname 10 | ``` 11 | 12 | There are two main components: 13 | 14 | * The PostgreSQL `proxy` that intercepts & logs every query 15 | * The `webapp` which displays, analyzes & optimizes the queries 16 | 17 | # Requirements 18 | 19 | Node version 22+ is required (for node:sqlite built-in package) 20 | -------------------------------------------------------------------------------- /client/App.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&display=swap'); 2 | 3 | body, html { 4 | margin: 0; 5 | padding: 0; 6 | background-color: rgba(40, 40, 40, 1); 7 | font-family: "Inconsolata", monospace; 8 | font-optical-sizing: auto; 9 | font-weight: 400; 10 | font-style: normal; 11 | font-variation-settings: "wdth" 100; 12 | color: #fff; 13 | } -------------------------------------------------------------------------------- /client/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useContext } from 'react'; 2 | import { 3 | BrowserRouter as Router, 4 | Routes, 5 | Route, 6 | NavLink as RouterNavLink, 7 | } from 'react-router-dom'; 8 | import io from 'socket.io-client'; 9 | import styled from 'styled-components'; 10 | 11 | import './App.css'; 12 | 13 | import { QueryList } from './components/QueryList'; 14 | import { Configs } from './components/Configs'; 15 | import { About } from './components/About'; 16 | 17 | import { AppContext, AppProvider } from './context/AppContext'; 18 | import { MainProps } from 'shared/main_props'; 19 | 20 | /* -------------------------------------------------------------------------- */ 21 | /* Styles */ 22 | /* -------------------------------------------------------------------------- */ 23 | 24 | const Container = styled.div` 25 | font-family: 'Inconsolata', monospace; 26 | display: flex; 27 | flex-direction: column; 28 | height: 100vh; 29 | overflow: auto; 30 | background-color: rgba(40, 40, 40, 1); 31 | color: #fff; 32 | 33 | & code { 34 | background-color: rgba(255, 255, 255, 0.1); 35 | padding: 2px 4px; 36 | border-radius: 4px; 37 | } 38 | & pre > code { 39 | display: block; 40 | padding: 5px 7px; 41 | border-radius: 0; 42 | } 43 | `; 44 | 45 | const TextLogo = styled.div` 46 | font-size: 30px; 47 | font-weight: 700; 48 | text-transform: lowercase; 49 | letter-spacing: 2px; 50 | border: 1px solid color(display-p3 0.964 0.7613 0.3253); 51 | color: color(display-p3 0.964 0.7613 0.3253); 52 | background: linear-gradient(to right, rgba(86, 65, 9, 0.8) 25%, rgba(59, 40, 7, 0.8) 75%); 53 | display: inline-block; 54 | padding: 0 20px; 55 | margin-right: 10px; 56 | border-radius: 30px; 57 | position: relative; 58 | `; 59 | 60 | const NavBar = styled.div` 61 | display: flex; 62 | align-items: center; 63 | gap: 10px; 64 | padding: 10px 20px; 65 | background-color: rgba(0, 0, 0, 1); 66 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 67 | `; 68 | 69 | const DbInfo = styled.div` 70 | margin-left: auto; 71 | font-size: 14px; 72 | `; 73 | 74 | const InfoTable = styled.table` 75 | font-size: 12px; 76 | color: rgba(255, 255, 255, 0.8); 77 | border-collapse: collapse; 78 | 79 | th, td { 80 | padding: 2px 6px; 81 | } 82 | 83 | th { 84 | opacity: 0.5; 85 | } 86 | 87 | th:first-child, td:first-child { 88 | text-align: right; 89 | font-weight: 600; 90 | } 91 | `; 92 | 93 | const StyledNavLink = styled(RouterNavLink)` 94 | cursor: pointer; 95 | text-decoration: none; 96 | padding: 8px 12px; 97 | color: #fff; 98 | border: 2px solid transparent; 99 | 100 | &:hover { 101 | border-bottom-color: #ffffff77; 102 | } 103 | 104 | &.active { 105 | border-bottom-color: #fff; 106 | } 107 | `; 108 | 109 | const MainContent = styled.div` 110 | flex-grow: 1; 111 | padding: 20px; 112 | background-color: rgb(74, 73, 71); 113 | `; 114 | 115 | /* -------------------------------------------------------------------------- */ 116 | 117 | function NavBarContent({ args }: { args: MainProps['args'] }) { 118 | const { config } = useContext(AppContext); 119 | 120 | return ( 121 | <> 122 | dbpill 123 | {/* RouterNavLink automatically adds the `active` class */} 124 | Queries 125 | Config 126 | About 127 | 128 | {/* Show current DB connection info and LLM info */} 129 | {(() => { 130 | try { 131 | const dbUrl = new URL(args.db); 132 | const host = dbUrl.hostname; 133 | const port = dbUrl.port || '5432'; 134 | const dbName = dbUrl.pathname.replace(/^\/+/, ''); 135 | const proxyPort = args.proxyPort || 5433; 136 | 137 | // Get LLM info - only use config values 138 | const llmEndpoint = config?.llm_endpoint || 'anthropic'; 139 | const llmModel = config?.llm_model || 'claude-sonnet-4'; 140 | 141 | // Format LLM provider name for display 142 | let llmProvider = llmEndpoint; 143 | if (llmEndpoint === 'anthropic') { 144 | llmProvider = 'Anthropic'; 145 | } else if (llmEndpoint === 'openai') { 146 | llmProvider = 'OpenAI'; 147 | } else if (llmEndpoint.startsWith('http')) { 148 | // Custom URL - extract domain for display 149 | try { 150 | const url = new URL(llmEndpoint); 151 | llmProvider = url.hostname; 152 | } catch { 153 | llmProvider = 'Custom'; 154 | } 155 | } 156 | 157 | return ( 158 | 159 | 160 | 161 | 162 | Proxy 163 | {`:${proxyPort} → ${host}:${port}/${dbName}`} 164 | 165 | 166 | LLM 167 | {`${llmProvider} • ${llmModel}`} 168 | 169 | 170 | 171 | 172 | ); 173 | } catch (_) { 174 | return null; 175 | } 176 | })()} 177 | 178 | ); 179 | } 180 | 181 | function App({ args }: MainProps) { 182 | // Establish socket connection (same behaviour as before) 183 | useEffect(() => { 184 | const socket = io(); 185 | socket.on('connect', () => { 186 | console.log('connected to socket.io'); 187 | }); 188 | 189 | return () => { 190 | socket.disconnect(); 191 | }; 192 | }, []); 193 | 194 | return ( 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | } /> 205 | } /> 206 | } /> 207 | 208 | 209 | 210 | 211 | 212 | ); 213 | } 214 | 215 | export default App; -------------------------------------------------------------------------------- /client/assets/dbpill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayfer/dbpill/8d37f8f36d0111692de355ee12cd9406cd8eb2f2/client/assets/dbpill.png -------------------------------------------------------------------------------- /client/components/About.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const AboutContainer = styled.div` 4 | max-width: 600px; 5 | margin: 0 auto; 6 | line-height: 1.6; 7 | 8 | h1 { 9 | margin-bottom: 20px; 10 | color: color(display-p3 0.964 0.7613 0.3253); 11 | } 12 | 13 | h2 { 14 | margin-top: 30px; 15 | margin-bottom: 15px; 16 | color: #fff; 17 | } 18 | 19 | p { 20 | margin-bottom: 15px; 21 | color: rgba(255, 255, 255, 0.9); 22 | } 23 | 24 | a { 25 | color: color(display-p3 0.964 0.7613 0.3253); 26 | text-decoration: none; 27 | 28 | &:hover { 29 | text-decoration: underline; 30 | } 31 | } 32 | 33 | ul { 34 | margin-bottom: 15px; 35 | padding-left: 20px; 36 | 37 | li { 38 | margin-bottom: 8px; 39 | color: rgba(255, 255, 255, 0.9); 40 | } 41 | } 42 | `; 43 | 44 | export function About() { 45 | return ( 46 | 47 |

About dbpill

48 | 49 |

50 | dbpill is a PostgreSQL query performance monitoring and optimization tool 51 | that uses AI to automatically suggest database indexes to improve query performance. 52 |

53 | 54 |

Features

55 | 62 | 63 |

How it works

64 |

65 | dbpill acts as a transparent proxy between your application and PostgreSQL database. 66 | It captures and analyzes SQL queries, then uses AI to suggest optimal indexes 67 | that can significantly improve query performance. 68 |

69 | 70 |

Contact & Support

71 |

72 | Website: dbpill.com
73 | Email: help@dbpill.com
74 | Author: @mayfer 75 |

76 | 77 |

78 | For bug reports, feature requests, or general questions, please reach out via email. 79 |

80 |
81 | ); 82 | } -------------------------------------------------------------------------------- /client/components/Configs.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components'; 3 | import { AppContext } from '../context/AppContext'; 4 | import { ActionButton, LoadingIndicator } from '../styles/Styled'; 5 | import { adminApi } from '../utils/HttpApi'; 6 | 7 | /* -------------------------------------------------------------------------- */ 8 | /* Styles */ 9 | /* -------------------------------------------------------------------------- */ 10 | 11 | const Container = styled.div` 12 | max-width: 800px; 13 | margin: 0 auto; 14 | `; 15 | 16 | const Title = styled.h1` 17 | font-size: 24px; 18 | margin-bottom: 20px; 19 | color: #fff; 20 | `; 21 | 22 | const Form = styled.form` 23 | display: flex; 24 | flex-direction: column; 25 | gap: 20px; 26 | background-color: rgba(0, 0, 0, 0.3); 27 | padding: 30px; 28 | border-radius: 8px; 29 | border: 1px solid rgba(255, 255, 255, 0.1); 30 | `; 31 | 32 | const FormGroup = styled.div` 33 | display: flex; 34 | flex-direction: column; 35 | gap: 8px; 36 | `; 37 | 38 | const Label = styled.label` 39 | font-weight: 600; 40 | color: #fff; 41 | font-size: 14px; 42 | `; 43 | 44 | const Input = styled.input` 45 | padding: 12px; 46 | border: 1px solid rgba(255, 255, 255, 0.2); 47 | border-radius: 4px; 48 | background-color: rgba(255, 255, 255, 0.1); 49 | color: #fff; 50 | font-family: 'Inconsolata', monospace; 51 | font-size: 14px; 52 | 53 | &:focus { 54 | outline: none; 55 | border-color: color(display-p3 0.964 0.7613 0.3253); 56 | } 57 | 58 | &::placeholder { 59 | color: rgba(255, 255, 255, 0.5); 60 | } 61 | `; 62 | 63 | const Select = styled.select` 64 | padding: 12px; 65 | border: 1px solid rgba(255, 255, 255, 0.2); 66 | border-radius: 4px; 67 | background-color: rgba(255, 255, 255, 0.1); 68 | color: #fff; 69 | font-family: 'Inconsolata', monospace; 70 | font-size: 14px; 71 | 72 | &:focus { 73 | outline: none; 74 | border-color: color(display-p3 0.964 0.7613 0.3253); 75 | } 76 | 77 | option { 78 | background-color: #333; 79 | color: #fff; 80 | } 81 | `; 82 | 83 | const Button = styled.button` 84 | padding: 12px 24px; 85 | background-color: color(display-p3 0.964 0.7613 0.3253); 86 | color: #000; 87 | border: none; 88 | border-radius: 4px; 89 | font-weight: 600; 90 | cursor: pointer; 91 | font-family: 'Inconsolata', monospace; 92 | font-size: 14px; 93 | align-self: flex-start; 94 | 95 | &:hover { 96 | background-color: color(display-p3 0.9 0.7 0.3); 97 | } 98 | 99 | &:disabled { 100 | opacity: 0.5; 101 | cursor: not-allowed; 102 | } 103 | `; 104 | 105 | const Message = styled.div<{ type: 'success' | 'error' }>` 106 | padding: 12px; 107 | border-radius: 4px; 108 | background-color: ${props => props.type === 'success' ? 'rgba(0, 255, 0, 0.1)' : 'rgba(255, 0, 0, 0.1)'}; 109 | border: 1px solid ${props => props.type === 'success' ? 'rgba(0, 255, 0, 0.3)' : 'rgba(255, 0, 0, 0.3)'}; 110 | color: ${props => props.type === 'success' ? '#4CAF50' : '#F44336'}; 111 | font-size: 14px; 112 | `; 113 | 114 | const Description = styled.p` 115 | color: rgba(255, 255, 255, 0.7); 116 | font-size: 14px; 117 | margin-bottom: 20px; 118 | line-height: 1.5; 119 | `; 120 | 121 | const HelpText = styled.span` 122 | color: rgba(255, 255, 255, 0.5); 123 | font-size: 12px; 124 | margin-top: 4px; 125 | `; 126 | 127 | /* -------------------------------------------------------------------------- */ 128 | 129 | interface VendorApiKeys { 130 | anthropic?: string; 131 | openai?: string; 132 | xai?: string; 133 | google?: string; 134 | } 135 | 136 | const DEFAULT_MODELS = { 137 | anthropic: 'claude-sonnet-4-0', 138 | openai: 'o3', 139 | gemini: 'gemini-2.5-pro', 140 | grok: 'grok-3-beta' 141 | }; 142 | 143 | interface ConfigsState { 144 | loading: boolean; 145 | resetting: boolean; 146 | message: { type: 'success' | 'error'; text: string } | null; 147 | messageTarget?: 'config' | 'apiKeys'; 148 | endpointType: 'anthropic' | 'openai' | 'gemini' | 'grok' | 'custom'; 149 | customUrl: string; 150 | formData: { 151 | llm_model: string; 152 | llm_api_key: string; 153 | }; 154 | apiKeys: VendorApiKeys; 155 | } 156 | 157 | const InlineMessage = styled.span<{ type: 'success' | 'error' }>` 158 | color: ${props => (props.type === 'success' ? '#4CAF50' : '#F44336')}; 159 | font-size: 14px; 160 | `; 161 | 162 | export class Configs extends Component<{}, ConfigsState> { 163 | static contextType = AppContext; 164 | declare context: React.ContextType; 165 | 166 | constructor(props: {}) { 167 | super(props); 168 | 169 | this.state = { 170 | loading: false, 171 | resetting: false, 172 | message: null, 173 | messageTarget: undefined, 174 | endpointType: 'anthropic', 175 | customUrl: '', 176 | formData: { 177 | llm_model: '', 178 | llm_api_key: '' 179 | }, 180 | apiKeys: { 181 | anthropic: '', 182 | openai: '', 183 | xai: '', 184 | google: '' 185 | } 186 | }; 187 | } 188 | 189 | componentDidMount() { 190 | this.initializeFromConfig(); 191 | } 192 | 193 | componentDidUpdate(prevProps: {}, prevState: ConfigsState) { 194 | // Re-initialize if context becomes available 195 | if (this.context?.config && !prevState.formData.llm_model) { 196 | this.initializeFromConfig(); 197 | } 198 | } 199 | 200 | initializeFromConfig = () => { 201 | const { config } = this.context || {}; 202 | if (!config) return; 203 | 204 | const endpoint = config.llm_endpoint || 'anthropic'; 205 | const endpointType = 206 | endpoint === 'anthropic' || endpoint === 'openai' || endpoint === 'gemini' || endpoint === 'grok' 207 | ? (endpoint as ConfigsState['endpointType']) 208 | : 'custom'; 209 | 210 | const customUrl = endpointType === 'custom' ? endpoint : ''; 211 | 212 | const defaultModel = 213 | config.llm_model || 214 | (endpointType in DEFAULT_MODELS ? DEFAULT_MODELS[endpointType as keyof typeof DEFAULT_MODELS] : ''); 215 | 216 | this.setState({ 217 | endpointType, 218 | customUrl, 219 | formData: { 220 | llm_model: defaultModel, 221 | llm_api_key: config.llm_api_key || '' 222 | }, 223 | apiKeys: { 224 | anthropic: config.apiKeys?.anthropic || '', 225 | openai: config.apiKeys?.openai || '', 226 | xai: config.apiKeys?.xai || '', 227 | google: config.apiKeys?.google || '' 228 | } 229 | }); 230 | }; 231 | 232 | /* ------------------------------ Handlers ------------------------------ */ 233 | 234 | handleEndpointChange = (e: React.ChangeEvent) => { 235 | const newType = e.target.value as ConfigsState['endpointType']; 236 | 237 | this.setState( 238 | prev => ({ 239 | endpointType: newType, 240 | customUrl: newType === 'custom' ? 'https://' : '', 241 | formData: { 242 | ...prev.formData, 243 | llm_model: 244 | newType !== 'custom' && newType in DEFAULT_MODELS 245 | ? DEFAULT_MODELS[newType as keyof typeof DEFAULT_MODELS] 246 | : prev.formData.llm_model 247 | } 248 | }), 249 | () => this.save('config') 250 | ); 251 | }; 252 | 253 | handleInputChange = (e: React.ChangeEvent) => { 254 | const { name, value } = e.target; 255 | this.setState(prev => ({ 256 | formData: { 257 | ...prev.formData, 258 | [name]: value 259 | } 260 | })); 261 | }; 262 | 263 | handleCustomUrlChange = (e: React.ChangeEvent) => { 264 | this.setState({ customUrl: e.target.value }); 265 | }; 266 | 267 | handleApiKeyChange = (vendor: keyof VendorApiKeys, value: string) => { 268 | this.setState(prev => ({ 269 | apiKeys: { 270 | ...prev.apiKeys, 271 | [vendor]: value 272 | } 273 | })); 274 | }; 275 | 276 | handleKeyDown = (target: 'config' | 'apiKeys') => (e: React.KeyboardEvent) => { 277 | if (e.key === 'Enter') { 278 | e.preventDefault(); 279 | this.save(target); 280 | } 281 | }; 282 | 283 | /* ------------------------------ Save Logic ---------------------------- */ 284 | 285 | save = async (target: 'config' | 'apiKeys') => { 286 | if (this.state.loading) return; 287 | 288 | this.setState({ loading: true, message: null, messageTarget: target }); 289 | 290 | const { updateConfig } = this.context; 291 | const { endpointType, customUrl, formData, apiKeys } = this.state; 292 | 293 | const submitData = { 294 | llm_endpoint: endpointType === 'custom' ? customUrl : endpointType, 295 | ...formData, 296 | apiKeys 297 | }; 298 | 299 | try { 300 | await updateConfig(submitData); 301 | this.setState({ loading: false, message: { type: 'success', text: 'Saved!' }, messageTarget: target }); 302 | setTimeout(() => this.setState({ message: null }), 2500); 303 | } catch (error) { 304 | console.error('Error updating config:', error); 305 | this.setState({ loading: false, message: { type: 'error', text: 'Save failed' }, messageTarget: target }); 306 | } 307 | }; 308 | 309 | /* ---------------------------- Reset Handler --------------------------- */ 310 | 311 | handleReset = async () => { 312 | if (!confirm('Are you sure you want to clear all query logs? This action cannot be undone.')) return; 313 | 314 | this.setState({ resetting: true }); 315 | try { 316 | await adminApi.resetQueryLogs(); 317 | alert('Query logs have been cleared.'); 318 | } catch (error: any) { 319 | console.error('Error resetting query logs:', error); 320 | alert(error.message || 'Failed to reset query logs'); 321 | } finally { 322 | this.setState({ resetting: false }); 323 | } 324 | }; 325 | 326 | /* ------------------------------ Render ------------------------------- */ 327 | 328 | render() { 329 | const { config } = this.context; 330 | const { 331 | loading, 332 | resetting, 333 | message, 334 | messageTarget, 335 | endpointType, 336 | customUrl, 337 | formData, 338 | apiKeys 339 | } = this.state; 340 | 341 | if (!config) { 342 | return ( 343 | 344 | Loading Configuration... 345 | 346 | ); 347 | } 348 | 349 | return ( 350 | 351 | LLM Configuration 352 | 353 | Configure the Language Model settings for query optimization suggestions. 354 | These settings are stored locally and persist across restarts. 355 | 356 | 357 | {/* LLM Config Form */} 358 |
359 | 360 | 361 | 373 | Choose the LLM service provider 374 | 375 | 376 | {endpointType === 'custom' && ( 377 | 378 | 379 | 387 | Enter the full URL for your custom LLM endpoint 388 | 389 | )} 390 | 391 | 392 | 393 | 402 | Specify the model name/identifier for the LLM service 403 | 404 | 405 | {endpointType === 'custom' && ( 406 | 407 | 408 | 420 | API key for the custom LLM endpoint (stored locally) 421 | 422 | )} 423 | 424 |
425 | 428 | {messageTarget === 'config' && message && ( 429 | {message.text} 430 | )} 431 |
432 |
433 | 434 | {config.updated_at && ( 435 | 436 | Last updated: {new Date(config.updated_at).toLocaleString()} 437 | 438 | )} 439 | 440 | {/* API Keys Form */} 441 | API Keys 442 | 443 | Configure API keys for each LLM provider. These keys are stored locally and will be 444 | used automatically when you select the corresponding provider above. 445 | 446 | 447 |
448 | 449 | 450 | this.handleApiKeyChange('anthropic', e.target.value)} 455 | placeholder="sk-ant-..." 456 | autoComplete="off" 457 | inputMode="text" 458 | spellCheck={false} 459 | data-lpignore="true" 460 | /> 461 | 462 | 463 | 464 | 465 | this.handleApiKeyChange('openai', e.target.value)} 470 | placeholder="sk-..." 471 | autoComplete="off" 472 | inputMode="text" 473 | spellCheck={false} 474 | data-lpignore="true" 475 | /> 476 | 477 | 478 | 479 | 480 | this.handleApiKeyChange('xai', e.target.value)} 485 | placeholder="xai-..." 486 | autoComplete="off" 487 | inputMode="text" 488 | spellCheck={false} 489 | data-lpignore="true" 490 | /> 491 | 492 | 493 | 494 | 495 | this.handleApiKeyChange('google', e.target.value)} 500 | placeholder="AIza..." 501 | autoComplete="off" 502 | inputMode="text" 503 | spellCheck={false} 504 | data-lpignore="true" 505 | /> 506 | 507 | 508 |
509 | 512 | {messageTarget === 'apiKeys' && message && ( 513 | {message.text} 514 | )} 515 |
516 |
517 | 518 | {/* Maintenance */} 519 | Database Configuration 520 | 521 | You must configure the database connection string when launching the proxy:
522 | ./dbpill --db=postgres://user:password@host:port/database 523 |
524 | 525 | Maintenance 526 | Clear all captured query logs. 527 | 533 | {resetting ? Resetting... : 'Reset all ⌫'} 534 | 535 |
536 | ); 537 | } 538 | } -------------------------------------------------------------------------------- /client/components/QueryList.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useContext } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { AppContext } from '../context/AppContext'; 4 | import { queryApi } from '../utils/HttpApi'; 5 | import styled from 'styled-components'; 6 | import { QuerySuggestions } from './QuerySuggestions'; 7 | import dbpillDiagram from '../assets/dbpill_diagram.svg'; 8 | 9 | import { 10 | QuerySort, 11 | QuerySortOption, 12 | TableContainer, 13 | QueryCard, 14 | QueryContentSection, 15 | QueryText, 16 | QueryStatsSection, 17 | ActionButton, 18 | QueryActionsSection, 19 | LoadingIndicator, 20 | StatsTable, 21 | StatsTableBody, 22 | StatsTableRow, 23 | StatsTableLabelCell, 24 | StatsTableValueCell, 25 | NumUnit, 26 | } from '../styles/Styled'; 27 | 28 | import { QueryDetailsBar } from './QueryDetailsBar'; 29 | 30 | import { formatNumber } from '../utils/formatNumber'; 31 | import { highlightSQL } from '../utils/sqlHighlighter'; 32 | 33 | // --- Local styled components for the new bottom tab bar redesign --- 34 | 35 | const CardWrapper = styled.div` 36 | display: flex; 37 | flex-direction: column; 38 | `; 39 | 40 | const StatsHeader = styled.div` 41 | margin-bottom: 10px; 42 | padding-left: 5px; 43 | `; 44 | 45 | const StatsText = styled.div` 46 | color: rgba(255, 255, 255, 0.5); 47 | font-size: 14px; 48 | `; 49 | 50 | const InstructionsContainer = styled.div` 51 | max-width: 600px; 52 | margin: 0 auto; 53 | line-height: 1.6; 54 | 55 | h1 { 56 | margin-bottom: 20px; 57 | color: color(display-p3 0.964 0.7613 0.3253); 58 | } 59 | 60 | p { 61 | margin-bottom: 15px; 62 | color: rgba(255, 255, 255, 0.9); 63 | } 64 | `; 65 | 66 | export function QueryList() { 67 | const [stats, setStats] = useState([]); 68 | const [orderBy, setOrderBy] = useState('avg_exec_time'); 69 | const [orderDirection, setOrderDirection] = useState<'asc' | 'desc'>('desc'); 70 | const [loadingSuggestions, setLoadingSuggestions] = useState<{ [key: string]: boolean }>({}); 71 | const [rerunning, setRerunning] = useState<{ [key: string]: boolean }>({}); 72 | const [expandedQueries, setExpandedQueries] = useState<{ [key: string]: boolean }>({}); 73 | const navigate = useNavigate(); 74 | const { args } = useContext(AppContext); 75 | 76 | const toggleQueryExpansion = (queryId: string) => { 77 | setExpandedQueries(prev => ({ 78 | ...prev, 79 | [queryId]: !prev[queryId], 80 | })); 81 | }; 82 | 83 | 84 | 85 | const order = (column: string) => { 86 | if (orderBy === column) { 87 | setOrderDirection(orderDirection === 'desc' ? 'asc' : 'desc'); 88 | } else { 89 | setOrderDirection('desc'); 90 | } 91 | setOrderBy(column); 92 | }; 93 | 94 | const handleRerun = async (queryId: string) => { 95 | setRerunning(prev => ({ ...prev, [queryId]: true })); 96 | try { 97 | const data = await queryApi.analyzeQuery(queryId); 98 | setStats(prevStats => { 99 | const newStats = [...prevStats]; 100 | const idx = newStats.findIndex(s => s.query_id === parseInt(queryId)); 101 | if (idx !== -1) newStats[idx] = { ...newStats[idx], ...data }; 102 | return newStats; 103 | }); 104 | } catch (error) { 105 | console.error('Error rerunning query:', error); 106 | } finally { 107 | setRerunning(prev => ({ ...prev, [queryId]: false })); 108 | } 109 | }; 110 | 111 | const getSuggestions = async (query_id: string) => { 112 | if (loadingSuggestions[query_id]) { 113 | return; 114 | } 115 | 116 | // Find any custom prompt stored in the current stats array for this query 117 | const currentStat = stats.find((s) => s.query_id === parseInt(query_id)); 118 | const promptOverride = currentStat?.prompt_preview; 119 | 120 | setLoadingSuggestions(prev => ({ ...prev, [query_id]: true })); 121 | try { 122 | const data = await queryApi.getSuggestions(query_id, promptOverride); 123 | setStats((prevStats) => { 124 | const newStats = [...prevStats]; 125 | const index = newStats.findIndex((stat) => stat.query_id === parseInt(query_id)); 126 | if (index !== -1) { 127 | newStats[index] = { 128 | ...newStats[index], 129 | ...data, 130 | }; 131 | } 132 | return newStats; 133 | }); 134 | } catch (err: any) { 135 | alert(err.message); 136 | } finally { 137 | setLoadingSuggestions(prev => ({ ...prev, [query_id]: false })); 138 | } 139 | }; 140 | 141 | 142 | 143 | useEffect(() => { 144 | const loadQueries = async () => { 145 | try { 146 | const data = await queryApi.getAllQueries(orderBy, orderDirection); 147 | setStats(data.stats); 148 | setOrderBy(data.orderBy); 149 | setOrderDirection(data.orderDirection as 'asc' | 'desc'); 150 | } catch (error) { 151 | console.error('Error loading queries:', error); 152 | } 153 | }; 154 | 155 | loadQueries(); 156 | }, [orderBy, orderDirection]); 157 | 158 | const columns = stats[0] ? Object.keys(stats[0]) : []; 159 | if (columns.includes('query_id')) { 160 | columns.splice(columns.indexOf('query_id'), 1); 161 | } 162 | 163 | const dbUrl = new URL(args.db); 164 | const dbUser = dbUrl.username; 165 | const dbName = dbUrl.pathname.replace(/^\/+/, ''); 166 | 167 | // Show instructions if no queries are available 168 | if (stats.length === 0) { 169 | return ( 170 | 171 |

Instructions

172 | {args && ( 173 | <> 174 |

175 | dbpill is running on port {args.proxyPort}
postgresql://{dbUser}@localhost:{args.proxyPort}/{dbName} 176 |

177 |

178 | Change your app's PostgreSQL connection to port {args.proxyPort} to start intercepting queries. 179 |

180 |

Once you start using your app & running queries through dbpill, they will appear here for analysis and optimization.

181 | dbpill workflow diagram 182 | 183 | )} 184 |
185 | ); 186 | } 187 | 188 | return ( 189 |
190 | 191 | 192 | {stats.length} unique queries captured{' '} 193 | {stats.reduce((acc, stat) => acc + stat.num_instances, 0)} times 194 | 195 | 196 | 197 | 198 | order('avg_exec_time')} 200 | $active={orderBy === 'avg_exec_time' ? 'true' : undefined} 201 | > 202 | {orderBy === 'avg_exec_time' && (orderDirection === 'asc' ? '▲' : '▼')} Avg time 203 | 204 | order('total_time')} 206 | $active={orderBy === 'total_time' ? 'true' : undefined} 207 | > 208 | {orderBy === 'total_time' && (orderDirection === 'asc' ? '▲' : '▼')} Total time 209 | 210 | 211 | order('max_exec_time')} 213 | $active={orderBy === 'max_exec_time' ? 'true' : undefined} 214 | > 215 | {orderBy === 'max_exec_time' && (orderDirection === 'asc' ? '▲' : '▼')} Max time 216 | 217 | order('num_instances')} 219 | $active={orderBy === 'num_instances' ? 'true' : undefined} 220 | > 221 | {orderBy === 'num_instances' && (orderDirection === 'asc' ? '▲' : '▼')} Run count 222 | 223 | order('prev_exec_time/new_exec_time')} 225 | $active={orderBy === 'prev_exec_time/new_exec_time' ? 'true' : undefined} 226 | > 227 | {orderBy === 'prev_exec_time/new_exec_time' && (orderDirection === 'asc' ? '▲' : '▼')} Improvements 228 | 229 | 230 | 231 | 232 | {stats.map((stat, index) => { 233 | const isExpanded = expandedQueries[stat.query_id]; 234 | const hasPerformanceData = stat.new_exec_time && stat.prev_exec_time; 235 | const improvement = hasPerformanceData ? stat.prev_exec_time / stat.new_exec_time : 0; 236 | 237 | return ( 238 | 239 | 240 | 241 | toggleQueryExpansion(stat.query_id)}> 242 | {highlightSQL(stat.query)} 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | Total 251 | 252 | {formatNumber(stat.total_time)} ms from {stat.num_instances} {stat.num_instances === 1 ? 'run' : 'runs'} 253 | 254 | 255 | 256 | Avg 257 | {formatNumber(stat.avg_exec_time)} ms 258 | 259 | 260 | Min 261 | {formatNumber(stat.min_exec_time)} ms 262 | 263 | 264 | Max 265 | {formatNumber(stat.max_exec_time)} ms 266 | 267 | 268 | Last 269 | {formatNumber(stat.last_exec_time)} ms 270 | 271 | 272 | 273 | 274 | handleRerun(stat.query_id.toString())} 277 | disabled={rerunning[stat.query_id]} 278 | style={{ marginTop: '8px', alignSelf: 'flex-start' }} 279 | > 280 | {rerunning[stat.query_id] ? Running... : ( 281 | <> 282 | ↻ Run again with random params 283 | 284 | )} 285 | 286 | 287 | 288 | 289 | 296 | 297 | 298 | 299 | 305 | 306 | ); 307 | })} 308 | 309 |
310 | ); 311 | } -------------------------------------------------------------------------------- /client/components/QuerySuggestions.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { queryApi } from '../utils/HttpApi'; 3 | import { 4 | ActionButton, 5 | LoadingIndicator, 6 | } from '../styles/Styled'; 7 | import { SuggestionBox } from './SuggestionBox'; 8 | 9 | interface QuerySuggestionsProps { 10 | stat: any; 11 | loadingSuggestions: { [key: string]: boolean }; 12 | setLoadingSuggestions: (fn: (prev: { [key: string]: boolean }) => { [key: string]: boolean }) => void; 13 | setStats: (fn: (prevStats: any[]) => any[]) => void; 14 | getSuggestions: (queryId: string) => Promise; 15 | } 16 | 17 | export function QuerySuggestions({ 18 | stat, 19 | loadingSuggestions, 20 | setLoadingSuggestions, 21 | setStats, 22 | getSuggestions, 23 | }: QuerySuggestionsProps) { 24 | const createManualSuggestion = async () => { 25 | setLoadingSuggestions(prev => ({ ...prev, [stat.query_id]: true })); 26 | 27 | try { 28 | const data = await queryApi.createManualSuggestion(stat.query_id); 29 | setStats(prevStats => { 30 | const newStats = [...prevStats]; 31 | const index = newStats.findIndex(s => s.query_id === stat.query_id); 32 | if (index !== -1) { 33 | newStats[index] = data; 34 | } 35 | return newStats; 36 | }); 37 | } catch (error: any) { 38 | alert(error.message || 'Error creating manual suggestion'); 39 | } finally { 40 | setLoadingSuggestions(prev => ({ ...prev, [stat.query_id]: false })); 41 | } 42 | }; 43 | 44 | const handleSuggestionUpdate = (updatedStat: any) => { 45 | setStats(prevStats => { 46 | const newStats = [...prevStats]; 47 | const index = newStats.findIndex(s => s.query_id === stat.query_id); 48 | if (index !== -1) { 49 | newStats[index] = updatedStat; 50 | } 51 | return newStats; 52 | }); 53 | }; 54 | 55 | const handleSuggestionDelete = (suggestionIndex: number) => { 56 | // This will be handled by the API response in handleSuggestionUpdate 57 | // No additional action needed here 58 | }; 59 | 60 | // No LLM response yet - show initial buttons 61 | if (!stat.llm_response) { 62 | return ( 63 | <> 64 | getSuggestions(stat.query_id)} 67 | disabled={loadingSuggestions[stat.query_id]} 68 | > 69 | {loadingSuggestions[stat.query_id] ? ( 70 | Getting suggestions... 71 | ) : ( 72 | '🤖 Get AI suggested indexes' 73 | )} 74 | 75 | 81 | {loadingSuggestions[stat.query_id] ? ( 82 | Creating... 83 | ) : ( 84 | '✎ Add custom index' 85 | )} 86 | 87 | 88 | ); 89 | } 90 | 91 | // Has LLM response - render suggestions 92 | return ( 93 | <> 94 | {/* Render list of suggestions if available */} 95 | {stat.suggestions && Array.isArray(stat.suggestions) && stat.suggestions.length > 0 ? ( 96 | stat.suggestions.slice().reverse().map((suggestion: any, reverseIdx: number) => { 97 | // Since we reversed the array, reverseIdx 0 = oldest suggestion, should be numbered 1 98 | const suggestionNumber = reverseIdx + 1; 99 | const statusText = stat.suggestions.length > 1 ? `Suggestion ${suggestionNumber}` : 'Suggestion'; 100 | // Use the original index for the key (newest suggestions have lower original indexes) 101 | const originalIdx = stat.suggestions.length - 1 - reverseIdx; 102 | 103 | return ( 104 | 113 | ); 114 | }) 115 | ) : stat.suggested_indexes && ( 116 | // Legacy single suggestion - convert to same format and use unified renderer 117 | 131 | )} 132 | 133 | {!stat.suggested_indexes && ( 134 |

No new index suggestions

135 | )} 136 | 137 | {!stat.applied_indexes && ( 138 |
139 | getSuggestions(stat.query_id)} 143 | disabled={loadingSuggestions[stat.query_id]} 144 | > 145 | {loadingSuggestions[stat.query_id] ? ( 146 | Getting more suggestions... 147 | ) : ( 148 | '🤖 Ask for more' 149 | )} 150 | 151 | 157 | {loadingSuggestions[stat.query_id] ? ( 158 | Creating... 159 | ) : ( 160 | '✎ Add custom' 161 | )} 162 | 163 |
164 | )} 165 | 166 | ); 167 | } -------------------------------------------------------------------------------- /client/components/SuggestionBox.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { queryApi } from '../utils/HttpApi'; 3 | import { 4 | ActionButton, 5 | LoadingIndicator, 6 | StatusTag, 7 | SuggestionContent, 8 | HighlightedSQL, 9 | StatsTable, 10 | StatsTableBody, 11 | StatsTableRow, 12 | StatsTableLabelCell, 13 | StatsTableValueCell, 14 | StatsTableImprovementCell, 15 | SuggestionTitleBar, 16 | SuggestionTitleGroup, 17 | SuggestionActionGroup, 18 | SuggestionContainer, 19 | DeleteSuggestionButton, 20 | PerformanceBadge, 21 | NumUnit, 22 | } from '../styles/Styled'; 23 | import { formatNumber } from '../utils/formatNumber'; 24 | import { highlightSQL } from '../utils/sqlHighlighter'; 25 | 26 | interface SuggestionBoxProps { 27 | suggestion: any; 28 | queryId: string; 29 | suggestionIndex: number; 30 | statusText: string; 31 | onUpdate: (updatedStat: any) => void; 32 | onDelete: (suggestionIndex: number) => void; 33 | } 34 | 35 | export function SuggestionBox({ 36 | suggestion, 37 | queryId, 38 | suggestionIndex, 39 | statusText, 40 | onUpdate, 41 | onDelete, 42 | }: SuggestionBoxProps) { 43 | const [isLoading, setIsLoading] = useState(false); 44 | const [isEditing, setIsEditing] = useState(false); 45 | const [editedIndexes, setEditedIndexes] = useState(suggestion.suggested_indexes || ''); 46 | const [originalIndexes, setOriginalIndexes] = useState(suggestion.suggested_indexes || ''); 47 | const [hasBeenEdited, setHasBeenEdited] = useState(false); 48 | 49 | const isReverted = !!suggestion.reverted; 50 | const isApplied = !!suggestion.applied && !isReverted; 51 | const isSuggested = !suggestion.applied && !isReverted; 52 | const status: 'reverted' | 'applied' | 'suggested' = isReverted ? 'reverted' : isApplied ? 'applied' : 'suggested'; 53 | 54 | const startEdit = () => { 55 | const initialContent = suggestion.suggested_indexes || ''; 56 | setEditedIndexes(initialContent); 57 | setOriginalIndexes(initialContent); 58 | setIsEditing(true); 59 | }; 60 | 61 | const cancelEdit = () => { 62 | setIsEditing(false); 63 | setEditedIndexes(originalIndexes); 64 | }; 65 | 66 | const saveEdit = async () => { 67 | setIsLoading(true); 68 | 69 | try { 70 | const requestBody: any = { 71 | query_id: queryId, 72 | suggested_indexes: editedIndexes 73 | }; 74 | 75 | if (suggestion.suggestion_id) { 76 | requestBody.suggestion_id = suggestion.suggestion_id; 77 | } 78 | 79 | const data = await queryApi.saveEditedIndexes(queryId, editedIndexes, suggestion.suggestion_id); 80 | onUpdate(data); 81 | setHasBeenEdited(true); 82 | setIsEditing(false); 83 | } catch (error: any) { 84 | alert(error.message || 'Error saving edited indexes'); 85 | } finally { 86 | setIsLoading(false); 87 | } 88 | }; 89 | 90 | const applySuggestion = async () => { 91 | setIsLoading(true); 92 | try { 93 | const data = await queryApi.applySuggestions(queryId, suggestion.suggestion_id); 94 | onUpdate(data); 95 | } catch (error: any) { 96 | alert(error.message); 97 | } finally { 98 | setIsLoading(false); 99 | } 100 | }; 101 | 102 | const revertSuggestion = async () => { 103 | setIsLoading(true); 104 | try { 105 | const data = await queryApi.revertSuggestions(queryId, suggestion.suggestion_id); 106 | onUpdate(data); 107 | } catch (error: any) { 108 | alert(error.message); 109 | } finally { 110 | setIsLoading(false); 111 | } 112 | }; 113 | 114 | const deleteSuggestion = async () => { 115 | if (!confirm('Are you sure you want to delete this suggestion?')) { 116 | return; 117 | } 118 | 119 | setIsLoading(true); 120 | 121 | try { 122 | const data = await queryApi.deleteSuggestion(suggestion.suggestion_id); 123 | onDelete(suggestionIndex); 124 | onUpdate(data); 125 | } catch (error: any) { 126 | alert(error.message || 'Error deleting suggestion'); 127 | } finally { 128 | setIsLoading(false); 129 | } 130 | }; 131 | 132 | const renderActions = () => { 133 | return ( 134 | 135 | {isSuggested && !isEditing && ( 136 | <> 137 | 143 | ✎ Edit 144 | 145 | 150 | {isLoading ? ( 151 | Applying... 152 | ) : ( 153 | `⬇ Apply Index${suggestion.suggested_indexes && suggestion.suggested_indexes.trim().split(';').filter(line => line.trim()).length > 1 ? 'es' : ''}` 154 | )} 155 | 156 | 157 | )} 158 | 159 | {isSuggested && isEditing && ( 160 | <> 161 | {editedIndexes !== originalIndexes && ( 162 | 168 | {isLoading ? ( 169 | Saving... 170 | ) : ( 171 | '💾 Save' 172 | )} 173 | 174 | )} 175 | 181 | {isLoading ? ( 182 | Canceling... 183 | ) : ( 184 | '✕ Cancel' 185 | )} 186 | 187 | 188 | )} 189 | 190 | {isApplied && ( 191 | <> 192 | Applied 193 | 198 | {isLoading ? ( 199 | Reverting... 200 | ) : ( 201 | '⬆ Revert' 202 | )} 203 | 204 | 205 | )} 206 | 207 | {isReverted && !isEditing && ( 208 | <> 209 | 215 | ✎ Edit 216 | 217 | 222 | {isLoading ? ( 223 | Re-applying... 224 | ) : ( 225 | `⬇ Re-apply${suggestion.suggested_indexes && suggestion.suggested_indexes.trim().split(';').filter(line => line.trim()).length > 1 ? ' Indexes' : ''}` 226 | )} 227 | 228 | 229 | )} 230 | 231 | {isReverted && isEditing && ( 232 | <> 233 | {editedIndexes !== originalIndexes && ( 234 | 240 | {isLoading ? ( 241 | Saving... 242 | ) : ( 243 | '💾 Save' 244 | )} 245 | 246 | )} 247 | 253 | {isLoading ? ( 254 | Canceling... 255 | ) : ( 256 | '✕ Cancel' 257 | )} 258 | 259 | 260 | )} 261 | 262 | ); 263 | }; 264 | 265 | const renderContent = () => { 266 | const hasPerf = suggestion.prev_exec_time !== null && suggestion.new_exec_time !== null && 267 | suggestion.prev_exec_time !== undefined && suggestion.new_exec_time !== undefined; 268 | const improvementVal = hasPerf ? (suggestion.prev_exec_time / suggestion.new_exec_time) : 0; 269 | 270 | return ( 271 | 272 | {isEditing ? ( 273 |