diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/.env b/.env new file mode 100644 index 0000000..6c40fb1 --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +DB_INSTANCES='mysql01,cego_test_db' +MYSQL01_HOST=127.0.0.1 +MYSQL01_PORT=3306 +MYSQL01_USER=root + +CEGO_TEST_DB_HOST=127.0.0.1 +CEGO_TEST_DB_PORT=3306 +CEGO_TEST_DB_USER=root +CEGO_TEST_DB_DATABASE=testdb diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..1c2aa65 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24c756b --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# IDE +.idea diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e74ed9f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 4, + "semi": false, + "singleQuote": true +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..45ed792 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# use the official Bun image +# see all versions at https://hub.docker.com/r/oven/bun/tags +FROM oven/bun:1.1 as base +WORKDIR /usr/src/app + +# install dependencies into temp directory +# this will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lockb /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lockb /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM base AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +ENV NODE_ENV=production +RUN bun run build + +CMD ["bun", "run", "start"] + diff --git a/README.md b/README.md index da7cb19..9ceb008 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,27 @@ -# mysql-admin -Show, Edit or Create PROCESSLIST, SLAVE resources +This is a small Next.js app for viewing and killing active processes on multiple MariaDB instances. + +### Features +- See all active processes, joined with any open transactions. +- Mark long-running open transactions with red. +- Sorted by transaction time desc, then by process time desc. +- Kill a process with two clicks. + +### Deployment +TODO: Link to dockerhub + +The image requires the following environment variables: +- `DB_INSTANCES` - A comma separated list of MariaDB instances to connect to. + +For each instance, the following environment variables are required: +- `{DB_INSTANCE_NAME}_HOST` - The hostname of the MariaDB instance. +- `{DB_INSTANCE_NAME}_PORT` - The port of the MariaDB instance. +- `{DB_INSTANCE_NAME}_USER` - The username to connect to the MariaDB instance. + +And optionally +- `{DB_INSTANCE_NAME}_PASSWORD` - The password to connect to the MariaDB instance. +- `{DB_INSTANCE_NAME}_DATABASE` - The database to connect to on the MariaDB instance. + +Example environment variables can be found [here](.env). + +### Development +To start a dev server, run `bun start dev` diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..06c8142 Binary files /dev/null and b/bun.lockb differ diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..3cf2d80 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +} + +export default nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..be8218a --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "mariadb-process-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start --port 80", + "lint": "next lint", + "prettier": "prettier --write ." + }, + "dependencies": { + "dotenv": "^16.4.5", + "mysql2": "^3.9.4", + "next": "14.1.4", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20.12.7", + "@types/react": "^18", + "@types/react-dom": "^18", + "autoprefixer": "^10.0.1", + "daisyui": "^4.10.1", + "eslint": "^8", + "eslint-config-next": "14.1.4", + "postcss": "^8", + "prettier": "^3.2.5", + "tailwindcss": "^3.3.0", + "typescript": "^5" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..fef1b22 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..d2f8422 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..3a5808d --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,59 @@ +import assert from 'assert' + +export type Instance = { + host: string + port: number + user: string + password: string | undefined + database: string | undefined +} + +export type Instances = { [key: string]: Instance } + +export type Config = { + instances: Instances +} + +export const getConfig = (): Config => { + // Load database instances from the environment variables + const dbInstancesEnv = process.env.DB_INSTANCES + assert(dbInstancesEnv, 'DB_INSTANCES is required') + const dbInstances = dbInstancesEnv.split(',') + + // For each db instance, load the host, port, user, password, and optional database + const instances: Instances = dbInstances.reduce((acc, dbInstance) => { + const dbInstanceUpper = dbInstance.toUpperCase() + const dbInstanceHostEnv = process.env[`${dbInstanceUpper}_HOST`] + assert(dbInstanceHostEnv, `${dbInstanceUpper}_HOST is required`) + const dbInstanceHost = dbInstanceHostEnv + + const dbInstancePortEnv = process.env[`${dbInstanceUpper}_PORT`] + assert(dbInstancePortEnv, `${dbInstanceUpper}_PORT is required`) + + const dbInstancePort = parseInt(dbInstancePortEnv) + assert(dbInstancePort, `${dbInstanceUpper}_PORT must be a number`) + + const dbInstanceUserEnv = process.env[`${dbInstanceUpper}_USER`] + assert(dbInstanceUserEnv, `${dbInstanceUpper}_USER is required`) + + const dbInstanceUser = dbInstanceUserEnv + + const dbInstancePassword = process.env[`${dbInstanceUpper}_PASSWORD`] + + const dbInstanceDatabase = process.env[`${dbInstanceUpper}_DATABASE`] + + acc[dbInstance] = { + host: dbInstanceHost, + port: dbInstancePort, + user: dbInstanceUser, + password: dbInstancePassword, + database: dbInstanceDatabase, + } + + return acc + }, {} as Instances) + + return { + instances, + } +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx new file mode 100644 index 0000000..322fb21 --- /dev/null +++ b/src/pages/_app.tsx @@ -0,0 +1,6 @@ +import '@/styles/globals.css' +import type { AppProps } from 'next/app' + +export default function App({ Component, pageProps }: AppProps) { + return +} diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx new file mode 100644 index 0000000..138cd8d --- /dev/null +++ b/src/pages/_document.tsx @@ -0,0 +1,13 @@ +import { Html, Head, Main, NextScript } from 'next/document' + +export default function Document() { + return ( + + + +
+ + + + ) +} diff --git a/src/pages/api/hello.ts b/src/pages/api/hello.ts new file mode 100644 index 0000000..96f00a0 --- /dev/null +++ b/src/pages/api/hello.ts @@ -0,0 +1,13 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from 'next' + +type Data = { + name: string +} + +export default function handler( + req: NextApiRequest, + res: NextApiResponse +) { + res.status(200).json({ name: 'John Doe' }) +} diff --git a/src/pages/api/kill.ts b/src/pages/api/kill.ts new file mode 100644 index 0000000..6c235a0 --- /dev/null +++ b/src/pages/api/kill.ts @@ -0,0 +1,37 @@ +// Given the id in post body, kill the mysql process with that id + +import { NextApiRequest, NextApiResponse } from 'next' +import { getConfig } from '@/lib/config' +import mysql from 'mysql2/promise' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === 'POST') { + const { id, instance } = req.body + if (!id) { + return res.status(400).json({ error: 'id is required' }) + } + + if (!instance) { + return res.status(400).json({ error: 'instance is required' }) + } + + const dbConfig = getConfig().instances[instance] + + if (!dbConfig) { + return res.status(400).json({ error: 'instance not found' }) + } + + // Kill the mysql process with the id + // Use the id to kill the mysql process + // res.status(200).json({ name: 'John Doe' }); + const conn = await mysql.createConnection(dbConfig) + await conn.query(`KILL ?`, [id]) + res.status(200).json({ id }) + } else { + res.setHeader('Allow', ['POST']) + res.status(405).end(`Method ${req.method} Not Allowed`) + } +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx new file mode 100644 index 0000000..e1d1b59 --- /dev/null +++ b/src/pages/index.tsx @@ -0,0 +1,37 @@ +import { GetServerSideProps, InferGetServerSidePropsType } from 'next' +import { Config, getConfig } from '@/lib/config' + +type Repo = { + instances: string[] +} + +export const getServerSideProps = (async () => { + // Load available instances + + const repo: Repo = { instances: Object.keys(getConfig().instances) } + // Pass data to the page via props + return { props: { repo } } +}) satisfies GetServerSideProps<{ repo: Repo }> + +export default function Home({ + repo, +}: InferGetServerSidePropsType) { + return ( +
+
+ +
+
    + {repo.instances.map((instance) => ( +
  • + {instance} +
  • + ))} +
+
+ ) +} diff --git a/src/pages/instance/[identifier]/index.tsx b/src/pages/instance/[identifier]/index.tsx new file mode 100644 index 0000000..f091cb7 --- /dev/null +++ b/src/pages/instance/[identifier]/index.tsx @@ -0,0 +1,325 @@ +import { GetServerSideProps, InferGetServerSidePropsType } from 'next' +import { RowDataPacket } from 'mysql2/promise' +import { useRouter } from 'next/router' +import Link from 'next/link' +import mysql from 'mysql2/promise' +import { getConfig } from '@/lib/config' + +type TransactionInfoDict = { + [threadId: number]: TransactionInfo +} + +type TransactionInfo = { + activeTime: number + info: string[] +} + +type Process = { + Id: number + User: string + Host: string + db: string | null + Command: string + Time: number + State: string + Info: string + Progress: number +} + +type ProcessWithTransaction = Process & { + transaction: TransactionInfo | null +} + +type Repo = { + processList: ProcessWithTransaction[] + innodbStatus: string +} + +const stringToColor = function (str: string | null): string { + if (!str) { + return '#000000' + } + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + let colour = '#' + for (let i = 0; i < 3; i++) { + let value = (hash >> (i * 8)) & 0xff + colour += ('00' + value.toString(16)).slice(-2) + } + return colour +} + +const blackOrWhite = function (hex: string): string { + const r = parseInt(hex.slice(1, 2), 16) + const g = parseInt(hex.slice(3, 2), 16) + const b = parseInt(hex.slice(5, 2), 16) + const brightness = (r * 299 + g * 587 + b * 114) / 1000 + return brightness > 155 ? '#000000' : '#ffffff' +} + +const parseInnoDbStatus = (innoDbStatus: string): TransactionInfoDict => { + const splitInnoDbStatus = innoDbStatus.split('\n') // Find the line LIST OF TRANSACTIONS FOR EACH SESSION:\n + const transactionsStartIndex = splitInnoDbStatus.findIndex((line) => + line.includes('LIST OF TRANSACTIONS FOR EACH SESSION:') + ) + + // After the transactionStartIndex, read transactions lines, splitting by lines starting with ---TRANSACTION, until we meet the line --------\n + const transactions: TransactionInfoDict = {} + + let transaction: TransactionInfo = { + activeTime: -1, + info: [], + } + + for (let i = transactionsStartIndex; i < splitInnoDbStatus.length; i++) { + const line = splitInnoDbStatus[i] + if (line.startsWith('--------')) { + break + } + + if (line.startsWith('---TRANSACTION')) { + // Get the active time from the format '..., ACTIVE 1 sec' + const index = line.indexOf(', ACTIVE') + const activeTime = parseInt(line.slice(index + 8)) + + transaction = { + activeTime, + info: [], + } + } + + if (line.startsWith('MariaDB thread id')) { + // Get the thread id from the format `MariaDB thread id 3, ...` + const threadId = parseInt(line.split(' ')[3]) + + transactions[threadId] = transaction + } + + transaction.info.push(line) + } + + return transactions +} + +export const getServerSideProps = (async (context) => { + const instance = getConfig().instances[context.query.identifier as string] + + if (!instance) { + return { + redirect: { + destination: '/', + permanent: false, + }, + } + } + + // Fetch data from external API + const conn = await mysql.createConnection(instance) + + const [processListResult] = await conn.query('SHOW PROCESSLIST;') + const processList: Process[] = processListResult as Process[] + const [innoDbStatusResult] = await conn.query( + 'SHOW ENGINE INNODB STATUS;' + ) + + const innoDbStatusString = innoDbStatusResult[0]['Status'] as string + // Convert the status string + const innoDbStatus = parseInnoDbStatus(innoDbStatusString as string) + + const processListWithTransaction: ProcessWithTransaction[] = + processList.map((process) => { + const transaction = innoDbStatus[process.Id] || null + return { + ...process, + transaction, + } + }) + + // Order by transaction.activeTime desc, then by process.Time desc + processListWithTransaction.sort((a, b) => { + if (a.transaction && !b.transaction) { + return -1 + } + if (!a.transaction && b.transaction) { + return 1 + } + if (a.transaction && b.transaction) { + return b.transaction.activeTime - a.transaction.activeTime + } + return b.Time - a.Time + }) + + const repo: Repo = { + processList: processListWithTransaction, + innodbStatus: innoDbStatusString, + } + // Pass data to the page via props + return { props: { repo } } +}) satisfies GetServerSideProps<{ repo: Repo }> + +export default function Home({ + repo, +}: InferGetServerSidePropsType) { + const router = useRouter() + return ( +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + {repo.processList.map( + (item: ProcessWithTransaction) => ( + 10 ? 'bg-red-300' : ''}`} + > + + + + + + + + + + + + + + ) + )} + + + + + + + + + + + + + + + + + +
🔥IdUserHostdbCommandTimeStateInfoProgressTransaction TimeTransaction Info
+ {/* Make kill button with skull emoji, on click send a request to /api/kill with the id in the body */} + + {item.Id} +
+ {item.User} +
+
{item.Host} +
+ {item.db} +
+
+ {item.Command} + + {item.Time} s + + {item.State} + {item.Info} + {item.Progress} + + {item.transaction?.activeTime} + {item.transaction?.activeTime + ? ' s' + : ''} + + {item.transaction?.info.join('\n')} +
🔥IdUserHostdbCommandTimeStateInfoProgressTransaction TimeTransaction Info
+
+
+ +
+ Click to see complete innodb status result. +
+
+ {repo.innodbStatus} +
+
+
+ ) +} diff --git a/src/styles/globals.css b/src/styles/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/styles/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/startDbAndLockSomeRows.sh b/startDbAndLockSomeRows.sh new file mode 100755 index 0000000..950ebbb --- /dev/null +++ b/startDbAndLockSomeRows.sh @@ -0,0 +1,3 @@ +docker run --detach -p 3306:3306 --name some-mariadb --env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 mariadb:latest || echo "Container already running" + +docker exec -it some-mariadb mariadb -e "CREATE DATABASE testdb; USE testdb; CREATE TABLE testtable2 (id INT, name VARCHAR(20)); INSERT INTO testtable2 VALUES (1, 'test1'); BEGIN; SELECT * FROM testtable WHERE id = 1 FOR UPDATE;" diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..dd45ffb --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,20 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': + 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + }, + }, + }, + plugins: [require('daisyui')], +} +export default config diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6c4cb60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}