├── client ├── public │ ├── favicon.ico │ └── index.html ├── babel.config.js ├── vue.config.js ├── src │ ├── plugins │ │ └── vuetify.js │ ├── components │ │ ├── misc │ │ │ ├── OperationBadge.vue │ │ │ ├── ButtonContainer.vue │ │ │ ├── SeveritySelector.vue │ │ │ └── DropSelect.vue │ │ ├── watcher │ │ │ ├── FilterField.vue │ │ │ ├── WatcherSettings.vue │ │ │ └── ObjectDiff.vue │ │ ├── settings │ │ │ ├── Darkmode.vue │ │ │ └── Config.vue │ │ ├── connection │ │ │ ├── HookIndicator.vue │ │ │ ├── ConnectionIndicator.vue │ │ │ ├── ConnectionEditor.vue │ │ │ ├── ManageDatabaseConnection.vue │ │ │ └── CreateDatabaseConnection.vue │ │ ├── commands │ │ │ ├── CommandTable.vue │ │ │ ├── CommandOutput.vue │ │ │ ├── ManageCommand.vue │ │ │ └── CommandCreateEdit.vue │ │ └── info-modals │ │ │ └── ConnectionInfoModal.vue │ ├── mixins │ │ └── color-helper.js │ ├── router │ │ └── index.js │ ├── views │ │ ├── Connections.vue │ │ ├── Commands.vue │ │ ├── Hooks.vue │ │ └── Watcher.vue │ ├── main.js │ └── App.vue ├── .gitignore ├── Dockerfile ├── README.md └── package.json ├── resource └── image │ ├── hooks.png │ ├── watcher.png │ ├── connections.png │ └── watcher_expanded.png ├── server ├── nodemon.json ├── tslint.json ├── tsconfig.json ├── src │ ├── domain │ │ ├── command.ts │ │ └── connection.ts │ ├── routes │ │ ├── config.ts │ │ ├── commands.ts │ │ ├── connections.ts │ │ └── hooks.ts │ ├── index.ts │ ├── pg-tools.ts │ ├── connection-store.ts │ ├── config-handler.ts │ └── pg-error-codes.ts ├── Dockerfile └── package.json ├── .gitignore ├── vetur.config.js ├── docker-compose.yml ├── LICENSE.md ├── .github └── workflows │ └── build.yml └── README.md /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukasLoeffler/pgtools/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /resource/image/hooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukasLoeffler/pgtools/HEAD/resource/image/hooks.png -------------------------------------------------------------------------------- /resource/image/watcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukasLoeffler/pgtools/HEAD/resource/image/watcher.png -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /resource/image/connections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukasLoeffler/pgtools/HEAD/resource/image/connections.png -------------------------------------------------------------------------------- /resource/image/watcher_expanded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukasLoeffler/pgtools/HEAD/resource/image/watcher_expanded.png -------------------------------------------------------------------------------- /server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": ".ts,.js", 4 | "ignore": [], 5 | "exec": "ts-node ./src/index.ts" 6 | } -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | 4 | module.exports = { 5 | "transpileDependencies": [ 6 | "vuetify" 7 | ] 8 | } -------------------------------------------------------------------------------- /client/src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify/lib'; 3 | 4 | Vue.use(Vuetify); 5 | 6 | export default new Vuetify({ 7 | }); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | /server/__pycache__ 3 | server/db.sqlite 4 | /server/static 5 | build.zip 6 | /server/.vscode 7 | /server/blueprints/__pycache__ 8 | 9 | /server/node_modules 10 | server/config.json 11 | /server/build 12 | /server/config/config.json -------------------------------------------------------------------------------- /server/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "trailing-comma": [ false ], 9 | "no-console": false 10 | }, 11 | "rulesDirectory": [] 12 | } -------------------------------------------------------------------------------- /vetur.config.js: -------------------------------------------------------------------------------- 1 | // vetur.config.js 2 | /** @type {import('vls').VeturConfig} */ 3 | module.exports = { 4 | settings: { 5 | "vetur.useWorkspaceDependencies": true, 6 | "vetur.experimental.templateInterpolationService": true 7 | }, 8 | projects: [ 9 | './client', 10 | ] 11 | } -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM node:lts-alpine as build-stage 3 | WORKDIR /app 4 | COPY package*.json ./ 5 | RUN npm install 6 | COPY . . 7 | RUN npm run build 8 | 9 | # production stage 10 | FROM nginx:stable-alpine as production-stage 11 | COPY --from=build-stage /app/dist /usr/share/nginx/html 12 | EXPOSE 80 13 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /client/src/components/misc/OperationBadge.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | 16 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # pgtools 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["es6"], 6 | "allowJs": true, 7 | "outDir": "build", 8 | "rootDir": "src", 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true 13 | } 14 | } -------------------------------------------------------------------------------- /server/src/domain/command.ts: -------------------------------------------------------------------------------- 1 | export enum Severity { 2 | LOW, 3 | MEDIUM, 4 | HIGH 5 | } 6 | 7 | export class Command { 8 | id: number; 9 | name: string; 10 | query: string; 11 | severity: Severity 12 | 13 | constructor(id: number, name: string, query: string, severity: Severity) { 14 | this.id = id; 15 | this.name = name; 16 | this.query = query; 17 | this.severity = severity; 18 | } 19 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | backend: 5 | container_name: pgtools-backend 6 | image: pgtools-backend:latest 7 | environment: 8 | - MODE=PROD 9 | build: ./server 10 | ports: 11 | - "5000:5000" 12 | volumes: 13 | - pgtools:/app/config 14 | restart: unless-stopped 15 | frontend: 16 | container_name: pgtools-frontend 17 | image: pgtools-frontend:latest 18 | build: ./client 19 | ports: 20 | - "80:80" 21 | 22 | 23 | 24 | volumes: 25 | pgtools: -------------------------------------------------------------------------------- /client/src/mixins/color-helper.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | getColorForAction(action) { 4 | if (action === "INSERT") return "green lighten-2"; 5 | if (action === "UPDATE") return "orange lighten-2"; 6 | if (action === "DELETE") return "red lighten-2"; 7 | }, 8 | getColorForSeverity(severity) { 9 | if (severity === "LOW") return "green lighten-2" 10 | if (severity === "MEDIUM") return "orange lighten-2" 11 | if (severity === "HIGH") return "red lighten-2" 12 | }, 13 | } 14 | } -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine as build-stage 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | COPY tsconfig*.json ./ 7 | COPY ./src ./src 8 | RUN npm ci --quiet && npm run build 9 | 10 | 11 | 12 | FROM node:lts-alpine 13 | 14 | WORKDIR /app 15 | 16 | COPY package*.json ./ 17 | RUN npm ci --quiet --only=production 18 | 19 | EXPOSE 5000 20 | 21 | ## We just need the build to execute the command 22 | COPY --from=build-stage /usr/src/app/build ./build 23 | 24 | RUN mkdir output 25 | RUN mkdir temp 26 | 27 | 28 | RUN mkdir -p /app/config 29 | 30 | CMD [ "node", "./build/index.js" ] -------------------------------------------------------------------------------- /server/src/routes/config.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express" 2 | import path from "path"; 3 | import { loadConfigFromFile } from '../config-handler' 4 | const fs = require("fs") 5 | 6 | var express = require('express'); 7 | const router = express.Router(); 8 | 9 | router.get('/', async (req: Request, res: Response) => { 10 | const config = await loadConfigFromFile(); 11 | res.send(config); 12 | }); 13 | 14 | router.get('/download', async (req: Request, res: Response) => { 15 | const configPath = path.join(__dirname, '../../config/config.json'); 16 | res.sendFile(configPath); 17 | }); 18 | 19 | module.exports = router; -------------------------------------------------------------------------------- /client/src/components/watcher/FilterField.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | -------------------------------------------------------------------------------- /client/src/components/misc/ButtonContainer.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | 24 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-ts", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start:dev": "nodemon", 8 | "build": "tsc", 9 | "start": "npm run build && node build/index.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@types/pg": "^8.6.1", 16 | "express": "^4.17.1", 17 | "nodemon": "^2.0.6", 18 | "pg": "^8.7.1", 19 | "pg-listen": "^1.7.0", 20 | "socket.io": "^4.2.0", 21 | "ts-node": "^9.0.0" 22 | }, 23 | "devDependencies": { 24 | "@types/express": "^4.17.13", 25 | "@types/node": "^14.14.2", 26 | "tslint": "^6.1.3", 27 | "typescript": "^4.0.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/src/components/misc/SeveritySelector.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 32 | 33 | -------------------------------------------------------------------------------- /client/src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | import Hooks from '../views/Hooks.vue' 4 | import Watcher from '../views/Watcher.vue' 5 | import Connections from '../views/Connections.vue' 6 | import Commands from '../views/Commands.vue' 7 | 8 | Vue.use(VueRouter) 9 | 10 | const routes = [ 11 | { 12 | path: '/', 13 | name: 'Connections', 14 | component: Connections 15 | }, 16 | { 17 | path: '/hooks', 18 | name: 'Hooks', 19 | component: Hooks, 20 | props: true 21 | }, 22 | { 23 | path: '/watcher', 24 | name: 'Watcher', 25 | component: Watcher 26 | }, 27 | { 28 | path: '/commands', 29 | name: 'Commands', 30 | component: Commands 31 | }, 32 | ] 33 | 34 | const router = new VueRouter({ 35 | routes, 36 | }) 37 | 38 | export default router 39 | 40 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 | 13 | 16 |
17 | 18 | 19 | 20 | 21 | 26 | 27 | -------------------------------------------------------------------------------- /client/src/components/settings/Darkmode.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | 26 | -------------------------------------------------------------------------------- /client/src/components/misc/DropSelect.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 40 | 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lukas Loeffler 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | # This workflow contains a single job called "build" 16 | build: 17 | # The type of runner that the job will run on 18 | runs-on: ubuntu-latest 19 | 20 | # Steps represent a sequence of tasks that will be executed as part of the job 21 | steps: 22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 23 | - uses: actions/checkout@master 24 | - uses: actions/setup-node@master 25 | - name: setup python 26 | uses: actions/setup-python@v2 27 | with: 28 | python-version: 3.8 #install the python needed 29 | - name: Installing project dependencies 30 | working-directory: ./client 31 | run: npm install 32 | - name: Building the project 33 | run: python build.py 34 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import express from "express" 3 | const app = express(); 4 | const http = require('http'); 5 | const server = http.createServer(app); 6 | import { Server, Socket } from "socket.io"; 7 | 8 | import cors from 'cors'; 9 | 10 | const PORT = 5000; 11 | 12 | app.use(cors()) 13 | app.use(express.json()) 14 | 15 | var connectionRoutes = require('./routes/connections'); 16 | var hookRoutes = require('./routes/hooks'); 17 | var commandRoutes = require('./routes/commands'); 18 | var configRoutes = require('./routes/config'); 19 | 20 | app.use('/connection', connectionRoutes); 21 | app.use('/hooks', hookRoutes); 22 | app.use('/command', commandRoutes); 23 | app.use('/config', configRoutes); 24 | 25 | 26 | app.get('/', (req: any, res: any) => { 27 | res.send({status: "running"}); 28 | }); 29 | 30 | 31 | server.listen(PORT, {origins: "*:*"}, () => { 32 | console.log(`Listening on: http://localhost:${PORT}`); 33 | }); 34 | 35 | 36 | const ioOptions = { 37 | cors: { 38 | origins: "*:*", 39 | credentials: false 40 | } 41 | } 42 | 43 | export const sio = new Server(server, ioOptions); 44 | 45 | sio.on("connection", (socket: Socket) => { 46 | // console.log("Client connected"); 47 | 48 | socket.on('disconnect', (reason: any) => { 49 | // console.log("Client disconnected"); 50 | }); 51 | }); -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pgtools", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.21.4", 12 | "core-js": "^3.17.3", 13 | "node-forge": "^1.0.0", 14 | "prismjs": "^1.27.0", 15 | "socket.io-client": "^4.2.0", 16 | "vue": "^2.6.11", 17 | "vue-prism-editor": "^1.3.0", 18 | "vue-router": "^3.2.0", 19 | "vuetify": "^2.2.11", 20 | "vuex": "^3.5.1" 21 | }, 22 | "devDependencies": { 23 | "@nuxtjs/vuetify": "^1.11.2", 24 | "@vue/cli-plugin-babel": "~4.4.0", 25 | "@vue/cli-plugin-eslint": "~4.4.0", 26 | "@vue/cli-plugin-router": "^4.4.6", 27 | "@vue/cli-service": "~4.4.0", 28 | "babel-eslint": "^10.1.0", 29 | "eslint": "^6.7.2", 30 | "eslint-plugin-vue": "^6.2.2", 31 | "sass": "~1.32", 32 | "sass-loader": "^10.1.1", 33 | "vue-cli-plugin-vuetify": "~2.0.6", 34 | "vue-template-compiler": "^2.6.11", 35 | "vuetify-loader": "^1.7.3" 36 | }, 37 | "eslintConfig": { 38 | "root": true, 39 | "env": { 40 | "node": true 41 | }, 42 | "extends": [ 43 | "plugin:vue/essential", 44 | "eslint:recommended" 45 | ], 46 | "parserOptions": { 47 | "parser": "babel-eslint" 48 | }, 49 | "rules": { 50 | "no-unused-vars": "off" 51 | } 52 | }, 53 | "browserslist": [ 54 | "> 1%", 55 | "last 2 versions", 56 | "not dead" 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /client/src/components/settings/Config.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 42 | 43 | -------------------------------------------------------------------------------- /server/src/pg-tools.ts: -------------------------------------------------------------------------------- 1 | const { Client } = require('pg') 2 | import { getConnectionById } from "./config-handler"; 3 | 4 | export async function generateGeneralTrigger(connectionId: string) { 5 | 6 | const connection = await getConnectionById(connectionId); 7 | 8 | const client = new Client(connection); 9 | 10 | await client.connect() 11 | 12 | const queryString = 13 | ` 14 | CREATE OR REPLACE FUNCTION notify_event() RETURNS TRIGGER AS $$ 15 | DECLARE 16 | payload JSON; 17 | payload_new JSON; 18 | payload_old JSON; 19 | BEGIN 20 | payload_new = json_build_object('', ''); 21 | payload_old = json_build_object('', ''); 22 | 23 | IF (TG_OP = 'DELETE') THEN 24 | payload_old = row_to_json(OLD); 25 | END IF; 26 | 27 | IF (TG_OP = 'INSERT') THEN 28 | payload_new = row_to_json(NEW); 29 | END IF; 30 | 31 | IF (TG_OP = 'UPDATE') THEN 32 | payload_new = row_to_json(NEW); 33 | payload_old = row_to_json(OLD); 34 | END IF; 35 | 36 | payload = json_build_object( 37 | 'table', TG_TABLE_NAME, 38 | 'action', TG_OP, 39 | 'data', payload_new, 40 | 'data_old', payload_old, 41 | 'database', current_database(), 42 | 'timestamp', transaction_timestamp() 43 | ); 44 | 45 | 46 | PERFORM pg_notify('pg_change', payload::text); 47 | 48 | RETURN NULL; 49 | END; 50 | $$ LANGUAGE plpgsql; 51 | ` 52 | await client.query(queryString) 53 | } -------------------------------------------------------------------------------- /client/src/components/connection/HookIndicator.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/components/connection/ConnectionIndicator.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | -------------------------------------------------------------------------------- /client/src/components/commands/CommandTable.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | -------------------------------------------------------------------------------- /client/src/components/commands/CommandOutput.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | -------------------------------------------------------------------------------- /server/src/domain/connection.ts: -------------------------------------------------------------------------------- 1 | const { Client } = require('pg') 2 | import { checkIfConnectionAlreadyExists } from "../config-handler"; 3 | import { getErrorByCode } from "../pg-error-codes"; 4 | 5 | class Validity { 6 | valid: boolean; 7 | message: string | undefined; 8 | 9 | constructor(valid: boolean, message: string | undefined) { 10 | this.valid = valid; 11 | this.message = message; 12 | } 13 | } 14 | 15 | export class Connection { 16 | id: string; 17 | name: string; 18 | database: string; 19 | user: string; 20 | password: string; 21 | host: string; 22 | port: number; 23 | 24 | constructor(id: string, name: string, database: string, user: string, password: string, host: string, port: number) { 25 | this.id = id; 26 | this.name = name; 27 | this.database = database; 28 | this.user = user; 29 | this.password = password; 30 | this.host = host; 31 | this.port = port; 32 | } 33 | 34 | static fromObject(obj: any): Connection { 35 | return new Connection(obj.id, obj.name, obj.database, obj.user, obj.password, obj.host, obj.port); 36 | } 37 | 38 | public tostring = () : string => { 39 | return `Connection (name: ${this.name})`; 40 | } 41 | 42 | public getClient() { 43 | return new Client({ 44 | user: this.user, 45 | host: this.host, 46 | database: this.database, 47 | password: this.password, 48 | port: this.port, 49 | client_encoding: "utf-8" 50 | }) 51 | } 52 | 53 | 54 | public async checkValidity(): Promise { 55 | 56 | const connectionAlreadyExists = await checkIfConnectionAlreadyExists(this.name); 57 | if (connectionAlreadyExists) { 58 | return { 59 | valid: false, 60 | message: "connection_already_exists" 61 | }; 62 | } 63 | 64 | const client = this.getClient(); 65 | 66 | try { 67 | await client.connect() 68 | return { 69 | valid: true, 70 | message: "success" 71 | }; 72 | } catch (error: any) { 73 | return { 74 | valid: false, 75 | message: getErrorByCode(error.code) 76 | }; 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /client/src/components/connection/ConnectionEditor.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /client/src/components/info-modals/ConnectionInfoModal.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 66 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pgtools 2 | 3 | Application for debugging applications that use postgres as DBMS. 4 | The application aims to help the user to understand their application by displaying the database events triggered by the application in real time. 5 | 6 | ## General 7 | 8 | Application built with NodeJS/Typescript (server-side) and vue.js (client-side). 9 | The sub-projects for front- and backend can be found in the respective folders. 10 | 11 | ## Deployment 12 | 13 | The application is intended to run on the local machine for debugging purposes. 14 | 15 | The application can be started with the ```docker-compose up -d``` command. 16 | 17 | ## Development 18 | 19 | ### Backend 20 | The development server can be started via 21 | ``` 22 | cd server 23 | npm install 24 | npm run start:dev 25 | ``` 26 | This spins up the application in development mode. Nodemon is used to hot-reload changes. 27 | 28 | Frontend 29 | ``` 30 | cd client 31 | npm install 32 | npm run serve 33 | ``` 34 | This spins up the VueJS development server. 35 | 36 | 37 | ## Note 38 | The application uses postgres trigger and trigger functions to intercept the database events and to forward them via pg_notify. For this reason, before using the application, you should check whether the used names of the trigger or trigger functions interfer with existing ones. 39 | Names used 40 | * triggers: **notify_trigger** 41 | * trigger functions: **notify_event** 42 | 43 | **IMPORTANT:** This application should only be used for debugging purposes with development databases 44 | 45 | 46 | ## Worklow 47 | 48 | Create Connection > Activate Connection > Avtivate Table Watch > Watch Tables 49 | 50 | Connections: Create new and manage existing database connections 51 | 52 | ![](resource/image/connections.png) 53 | 54 | Hooks: Define which database tables should be watched by the application 55 | 56 | ![](resource/image/hooks.png) 57 | 58 | Watcher: View events in realtime, filter events by database, table and id 59 | 60 | Expand table row to see which column has changed. By default only the columns with the change is shown. The detail view can be expanded via the blue plus icon to show the whole row/object (only available for UPDATE operations). Columns with changes are highlighted in blue. The default behavior can be changed in the settings so that the entire object is automatically displayed when opened. 61 | 62 | ![](resource/image/watcher.png) 63 | 64 | Watcher view with whole object expanded. 65 | 66 | ![](resource/image/watcher_expanded.png) 67 | 68 | -------------------------------------------------------------------------------- /server/src/routes/commands.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express" 2 | import { loadCommands, removeCommand, addCommand, updateCommand, getConnectionById, getCommandById } from '../config-handler' 3 | import { Command } from "../domain/command"; 4 | import { Connection } from "../domain/connection"; 5 | import { getErrorByCode } from "../pg-error-codes"; 6 | 7 | var express = require('express'); 8 | const router = express.Router(); 9 | 10 | router.get('/all', async (req: Request, res: Response) => { 11 | const allCommands = await loadCommands(); 12 | res.send(allCommands); 13 | }); 14 | 15 | router.post('/', async (req: Request, res: Response) => { 16 | const allCommands = await loadCommands(); 17 | const lastId = allCommands[allCommands.length - 1]?.id ?? 1; 18 | const command = new Command(lastId + 1, req.body.name, req.body.query, req.body.severity); 19 | await addCommand(command); 20 | res.send(); 21 | }); 22 | 23 | router.put('/', async (req: Request, res: Response) => { 24 | const command = new Command(req.body.id, req.body.name, req.body.query, req.body.severity); 25 | await updateCommand(command); 26 | const updatedCommand = await getCommandById(command.id) 27 | res.send(updatedCommand); 28 | }); 29 | 30 | router.post('/execute', async (req: Request, res: Response) => { 31 | const connectionId = req.body.connectionId; 32 | const query = req.body.query; 33 | const connection = await getConnectionById(connectionId); 34 | 35 | const connInstance = Connection.fromObject(connection); 36 | const client = connInstance.getClient(); 37 | await client.connect(); 38 | 39 | try { 40 | const start = process.hrtime(); 41 | let result = await client.query(query); 42 | const end = process.hrtime(start)[1] / 1000000; 43 | 44 | const output = { 45 | status: "success", 46 | payload: result.rows, 47 | message: null, 48 | rowCount: result.rowCount, 49 | command: result.command, 50 | elapsed: end.toFixed(1) 51 | } 52 | 53 | res.send(output); 54 | } catch (error: any) { 55 | const response = { 56 | "status": "error", 57 | "error_type": getErrorByCode(error.code), 58 | "message": getErrorByCode(error.code) 59 | } 60 | res.send(response); 61 | } 62 | }); 63 | 64 | router.delete('/:id', function(req: Request, res: Response){ 65 | removeCommand(parseInt(req.params.id)); 66 | res.send(); 67 | }); 68 | 69 | 70 | module.exports = router; -------------------------------------------------------------------------------- /server/src/connection-store.ts: -------------------------------------------------------------------------------- 1 | import createSubscriber, { Subscriber } from "pg-listen" 2 | import { sio } from "./index" 3 | 4 | import { getConnectionById as getConnection } from "./config-handler" 5 | import { Connection } from "./domain/connection"; 6 | 7 | class ConnectionInstance { 8 | id: string; 9 | subscriber: Subscriber; 10 | 11 | constructor(id: string, subscriber: Subscriber) { 12 | this.id = id; 13 | this.subscriber = subscriber; 14 | } 15 | } 16 | 17 | let connectionStore: Array = []; 18 | let eventIndex = 0; 19 | 20 | export async function startListen(connectionId: string) { 21 | const connection: Connection | undefined = await getConnection(connectionId); 22 | 23 | if (!connection) throw new Error(`Connection is not present`); 24 | 25 | const conString = `postgresql://${connection.user}:${connection.password}@${connection.host}:${connection.port}/${connection.database}` 26 | const subscriber: Subscriber = createSubscriber({ connectionString: conString }) 27 | 28 | subscriber.notifications.on("pg_change", (payload) => { 29 | payload.index = eventIndex; 30 | sio.emit("databaseEvent", payload) 31 | eventIndex++; 32 | }) 33 | 34 | subscriber.events.on("error", (error: Error) => console.log("Error:", error)); 35 | 36 | 37 | try { 38 | subscriber.connect() 39 | subscriber.listenTo("pg_change") 40 | 41 | const connection = new ConnectionInstance(connectionId, subscriber) 42 | addConnectionToStore(connection); 43 | } catch (error) { 44 | console.log(error); 45 | } 46 | } 47 | 48 | export function resetEventIndex() { 49 | eventIndex = 0; 50 | } 51 | 52 | function addConnectionToStore(connection: ConnectionInstance) { 53 | connectionStore.push(connection); 54 | } 55 | 56 | function getConnectionById(id: string): ConnectionInstance | undefined { 57 | return connectionStore.find(connection => connection.id === id); 58 | } 59 | 60 | function removeConnectionFromStore(id: string) { 61 | connectionStore = connectionStore.filter(connection => connection.id !== id) 62 | } 63 | 64 | export function endListen(id: string) { 65 | const connection = getConnectionById(id); 66 | removeConnectionFromStore(id); 67 | connection?.subscriber.close(); 68 | } 69 | 70 | export function getActiveConnections() { 71 | return connectionStore; 72 | } 73 | 74 | export function getConnectionStatus(id: string) { 75 | const connection = getConnectionById(id); 76 | return (connection === undefined) ? false : true; 77 | } -------------------------------------------------------------------------------- /client/src/components/watcher/WatcherSettings.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | -------------------------------------------------------------------------------- /client/src/views/Connections.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 94 | 95 | 97 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import axios from 'axios' 5 | import Vuetify from 'vuetify' 6 | import 'vuetify/dist/vuetify.min.css' 7 | import vuetify from './plugins/vuetify'; 8 | import Vuex from 'vuex' 9 | import io from "socket.io-client"; 10 | 11 | 12 | const PROTOCOL = location.protocol; 13 | const HOSTNAME = location.hostname; 14 | const PORT = 5000; 15 | export const BASE_URL = `${PROTOCOL}//${HOSTNAME}:${PORT}`; 16 | 17 | let socket = io(BASE_URL); 18 | 19 | Vue.use(Vuetify) 20 | Vue.use(Vuex) 21 | 22 | Vue.config.productionTip = false 23 | Vue.prototype.$http = axios 24 | 25 | 26 | const store = new Vuex.Store({ 27 | state: { 28 | activeConnections: [], 29 | events: [], 30 | eventSelection: [], 31 | websocketStatus: false, 32 | }, 33 | mutations: { 34 | //Sets a whole array of connections as active connections 35 | setActiveConnections (state, connections) { 36 | state.activeConnections = connections; 37 | }, 38 | //Adds connection to activeConnections if no connection with same id is present 39 | addActiveConnection (state, connection) { 40 | if (!state.activeConnections.some(existingConnection => existingConnection.id === connection.id)){ 41 | state.activeConnections.push(connection); 42 | } 43 | }, 44 | //Removed connection from active connection if existing 45 | removeActiveConnection(state, connection) { 46 | state.activeConnections = state.activeConnections.filter(existingConnection => existingConnection.id !== connection.id); 47 | }, 48 | addEvent(state, event) { 49 | state.events = [event, ...state.events]; 50 | }, 51 | setWebsocketStatus(state, status) { 52 | state.websocketStatus = status; 53 | }, 54 | resetEvents(state) { 55 | state.events = []; 56 | state.eventSelection = []; 57 | }, 58 | }, 59 | getters: { 60 | activeConnections: state => { 61 | return state.activeConnections; 62 | }, 63 | events: state => { 64 | return state.events; 65 | }, 66 | websocketStatus: state => { 67 | return state.websocketStatus; 68 | } 69 | } 70 | }) 71 | 72 | 73 | const CONNECTION_URL = `${BASE_URL}/connection/all/active` 74 | 75 | axios.get(CONNECTION_URL) 76 | .then((result) => { 77 | store.commit('setActiveConnections', result.data); 78 | }); 79 | 80 | socket.on("databaseEvent", event => { 81 | event.id = event.data.id || event.data_old.id; 82 | store.commit('addEvent', event) 83 | }); 84 | 85 | socket.on("connect", () => { 86 | console.log("%cWebsocketStatus: %cConnected", "font-weight: bold;", "color: green;"); 87 | store.commit('setWebsocketStatus', true); 88 | }); 89 | 90 | socket.on("disconnect", () => { 91 | console.log("%cWebsocketStatus: %cNot Connected", "font-weight: bold;", "color: red;"); 92 | store.commit('setWebsocketStatus', false); 93 | store.commit('setActiveConnections', []); 94 | }); 95 | 96 | new Vue({ 97 | router, 98 | store, 99 | vuetify, 100 | render: h => h(App) 101 | }).$mount('#app') -------------------------------------------------------------------------------- /server/src/routes/connections.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from '../domain/connection' 2 | import { loadConnections, addConnection, removeConnection, updateConnection } from '../config-handler' 3 | import { getActiveConnections, endListen, startListen, getConnectionStatus, resetEventIndex } from '../connection-store' 4 | import { Request, Response } from "express" 5 | import { generateGeneralTrigger } from '../pg-tools'; 6 | const crypto = require("crypto"); 7 | 8 | var express = require('express'); 9 | const router = express.Router(); 10 | 11 | 12 | router.post('/', async (req: Request, res: Response) => { 13 | try { 14 | const id = crypto.randomBytes(16).toString("hex"); 15 | const connection = new Connection(id, req.body.name, req.body.database, req.body.user, req.body.password, req.body.host, req.body.port); 16 | const connectionValidity = await connection.checkValidity(); 17 | if (!connectionValidity.valid) throw new Error(connectionValidity.message); 18 | await addConnection(connection); 19 | return res.send(connection); 20 | } catch (error: any) { 21 | return res.status(400).send({ message: error.message }); 22 | } 23 | }) 24 | 25 | router.put('/', async (req: Request, res: Response) => { 26 | try { 27 | const connection = new Connection(req.body.id, req.body.name, req.body.database, req.body.user, req.body.password, req.body.host, req.body.port); 28 | await updateConnection(connection); 29 | return res.send(connection); 30 | } catch (error: any) { 31 | return res.status(400).send({ message: error.message }); 32 | } 33 | }) 34 | 35 | router.get('/all', async (req: Request, res: Response) => { 36 | const allConnections = await loadConnections() 37 | res.send(allConnections); 38 | }); 39 | 40 | router.get('/by-id/:id', async (req: Request, res: Response) => { 41 | const connection = await 42 | res.send(startListen(req.params.id)); 43 | }); 44 | 45 | router.get('/all/active', function(req: Request, res: Response){ 46 | res.send(getActiveConnections()); 47 | }); 48 | 49 | router.get('/listen-start/:id', async (req: Request, res: Response) => { 50 | await generateGeneralTrigger(req.params.id) 51 | res.send(startListen(req.params.id)); 52 | }); 53 | 54 | router.get('/listen-end/:id', function(req: Request, res: Response){ 55 | res.send(endListen(req.params.id)); 56 | }); 57 | 58 | router.get('/status/:id', function(req: Request, res: Response){ 59 | res.send({connected: getConnectionStatus(req.params.id)}); 60 | }); 61 | 62 | router.get('/reset-index', function(req: Request, res: Response) { 63 | resetEventIndex(); 64 | res.send({ 65 | status: "success", 66 | message: "Index reset successful." 67 | }); 68 | }); 69 | 70 | router.post('/check', async (req: Request, res: Response) => { 71 | const connection = new Connection(req.body.id, req.body.name, req.body.database, req.body.user, req.body.password, req.body.host, req.body.port); 72 | const connectionValidity = await connection.checkValidity(); 73 | return res.send(connectionValidity); 74 | }) 75 | 76 | router.delete('/:name', function(req: Request, res: Response){ 77 | removeConnection(req.params.name); 78 | res.send(); 79 | }); 80 | 81 | module.exports = router; -------------------------------------------------------------------------------- /client/src/components/commands/ManageCommand.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /client/src/views/Commands.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 94 | 95 | 104 | -------------------------------------------------------------------------------- /server/src/config-handler.ts: -------------------------------------------------------------------------------- 1 | const fs = { ...require("fs"), ...require("fs/promises") }; 2 | import * as path from 'path'; 3 | import { Command } from './domain/command'; 4 | import { Connection } from './domain/connection'; 5 | 6 | 7 | const configPath = path.join(__dirname, '../config/config.json'); 8 | 9 | // If no config file exists initially, create an empty one 10 | fs.exists(configPath, function (exists: boolean) { 11 | if (!exists) { 12 | const emptyConfig = { 13 | connections: [], 14 | commands: [] 15 | } 16 | 17 | fs.writeFile(configPath, JSON.stringify(emptyConfig, null, 4), 'utf8') 18 | } 19 | }); 20 | 21 | export async function loadConfigFromFile(): Promise { 22 | let fileContents = await fs.readFile(configPath, 'utf8') 23 | fileContents = JSON.parse(fileContents); 24 | return fileContents; 25 | } 26 | 27 | export async function writeConfigToFile(config: any): Promise { 28 | let fileContents = await fs.writeFile(configPath, JSON.stringify(config, null, 4), 'utf8') 29 | } 30 | 31 | export async function loadConnections(): Promise> { 32 | const settings = await loadConfigFromFile(); 33 | return settings.connections; 34 | } 35 | 36 | export async function getConnectionById(id: string): Promise { 37 | const allConnections = await loadConnections(); 38 | return allConnections.find((connection: Connection) => connection.id === id); 39 | } 40 | 41 | export async function checkIfConnectionAlreadyExists(connectionId: string): Promise { 42 | const existingConnection = await getConnectionById(connectionId); 43 | return Boolean(existingConnection); 44 | } 45 | 46 | export async function addConnection(connection: Connection) { 47 | const connectionExists = await checkIfConnectionAlreadyExists(connection.name); 48 | if (!connectionExists) { 49 | let config = await loadConfigFromFile(); 50 | config.connections.push(connection); 51 | await writeConfigToFile(config) 52 | } else { 53 | console.log("connection_already_exists") 54 | } 55 | } 56 | 57 | export async function updateConnection(connection: Connection) { 58 | const connectionExists = await checkIfConnectionAlreadyExists(connection.id); 59 | if (connectionExists) { 60 | await removeConnection(connection.id); 61 | await addConnection(connection) 62 | } else { 63 | console.log("connection_does_not_exists") 64 | } 65 | } 66 | 67 | export async function removeConnection(connectionId: String) { 68 | let config = await loadConfigFromFile(); 69 | config.connections = config.connections.filter((connection: Connection) => connection.id !== connectionId); 70 | await writeConfigToFile(config) 71 | } 72 | 73 | export async function getCommandById(id: number): Promise { 74 | const allCommands = await loadCommands(); 75 | return allCommands.find((command: Command) => command.id === id); 76 | } 77 | 78 | export async function loadCommands(): Promise> { 79 | const settings = await loadConfigFromFile(); 80 | return settings.commands.sort((a: Command, b: Command) => a.id - b.id); 81 | } 82 | 83 | export async function removeCommand(commandId: number) { 84 | let config = await loadConfigFromFile(); 85 | config.commands = config.commands.filter((command: any) => command.id !== commandId); 86 | await writeConfigToFile(config) 87 | } 88 | 89 | export async function addCommand(command: Command) { 90 | let config = await loadConfigFromFile(); 91 | config.commands.push(command); 92 | await writeConfigToFile(config) 93 | } 94 | 95 | export async function updateCommand(command: Command) { 96 | await removeCommand(command.id); 97 | await addCommand(command); 98 | } 99 | -------------------------------------------------------------------------------- /client/src/components/connection/ManageDatabaseConnection.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /client/src/components/connection/CreateDatabaseConnection.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /client/src/components/watcher/ObjectDiff.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 124 | 125 | 151 | 152 | -------------------------------------------------------------------------------- /server/src/routes/hooks.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express" 2 | import { getConnectionById } from "../config-handler"; 3 | import { getErrorByCode } from "../pg-error-codes"; 4 | const { Client } = require('pg') 5 | 6 | var express = require('express'), 7 | router = express.Router(); 8 | 9 | class TableHook { 10 | table: string; 11 | type: string; 12 | schema: string; 13 | hookEnabled: boolean; 14 | 15 | constructor(table: string, type: string, schema: string, hookEnabled: boolean) { 16 | this.table = table; 17 | this.type = type; 18 | this.schema = schema; 19 | this.hookEnabled = hookEnabled; 20 | } 21 | } 22 | 23 | 24 | async function checkIfConnectionHasActiveHook(client: any, tableName: string) { 25 | const queryString = 26 | ` 27 | SELECT event_manipulation 28 | FROM information_schema.triggers 29 | WHERE event_object_table = '${tableName}' 30 | ORDER BY event_object_table, event_manipulation 31 | ` 32 | const result = await client.query(queryString) 33 | return Boolean(result.rows.length); 34 | } 35 | 36 | async function buildTableHookFromRow(client: any, row: any): Promise { 37 | const hookEnabled = await checkIfConnectionHasActiveHook(client, row.table_name) 38 | return new TableHook(row.table_name, row.table_type, row.table_schema, hookEnabled); 39 | } 40 | 41 | async function getAllTablesWithTriggers(client: any) { 42 | const publicTables = (await client.query("SELECT * FROM information_schema.tables WHERE table_schema = 'public'")).rows; 43 | const output: Array = []; 44 | 45 | for (const row of publicTables) { 46 | const tableHook = await buildTableHookFromRow(client, row); 47 | output.push(tableHook) 48 | }; 49 | 50 | return output; 51 | } 52 | 53 | router.get('/:id', async (req: Request, res: Response) => { 54 | const connection = await getConnectionById(req.params.id) 55 | if (connection) { 56 | const client = new Client(connection) 57 | client.connect() 58 | const output = await getAllTablesWithTriggers(client); 59 | res.send(output); 60 | client.end(); 61 | } else { 62 | res.send([]); 63 | } 64 | }); 65 | 66 | 67 | router.get('/:id/stats', async (req: Request, res: Response) => { 68 | try { 69 | const responseBody = await getHookStats(req.params.id); 70 | res.send(responseBody); 71 | } catch (error: any) { 72 | res.status(400).send(error); 73 | } 74 | }); 75 | 76 | router.post('/set/:name', async (req: Request, res: Response) => { 77 | const triggers = await setTriggerForTable(req.params.name, req.body); 78 | res.send(triggers); 79 | }); 80 | 81 | async function getAllTriggers(client: any) { 82 | const queryString = "SELECT * FROM information_schema.triggers" 83 | return (await client.query(queryString)).rows; 84 | } 85 | 86 | async function removeAllTriggers(client: any) { 87 | const allTriggers = await getAllTriggers(client); 88 | 89 | for (const trigger of allTriggers) { 90 | await removeTrigger(client, trigger.event_object_table, trigger.trigger_name) 91 | } 92 | } 93 | 94 | async function removeTrigger(client: any, tableName: string, triggerName: string) { 95 | const queryString = `DROP TRIGGER IF EXISTS ${triggerName} ON ${tableName} CASCADE` 96 | return await client.query(queryString); 97 | } 98 | 99 | 100 | 101 | async function createTriggerForTable(client: any, tableName: string, triggerName: string) { 102 | const queryString = 103 | ` 104 | CREATE TRIGGER ${triggerName} 105 | AFTER INSERT OR UPDATE OR DELETE ON ${tableName} 106 | FOR EACH ROW EXECUTE PROCEDURE notify_event(); 107 | ` 108 | return await client.query(queryString); 109 | } 110 | 111 | async function getHookStats(connectionId: string) { 112 | const connection = await getConnectionById(connectionId); 113 | const client = new Client(connection); 114 | 115 | try { 116 | await client.connect(); 117 | const tablesWithHooks = await getAllTablesWithTriggers(client); 118 | 119 | return { 120 | totalTables: tablesWithHooks.length, 121 | hookedTables: tablesWithHooks.filter(hook => hook.hookEnabled).length 122 | } 123 | } catch (error: any) { 124 | if (error.code) { 125 | throw { 126 | code: error.code, 127 | error: getErrorByCode(error.code), 128 | database: connection?.database 129 | } 130 | } else { 131 | throw error; 132 | } 133 | } 134 | } 135 | 136 | async function setTriggerForTable(connectionId: string, tableList: Array) { 137 | const connection = await getConnectionById(connectionId) 138 | 139 | const client = new Client(connection) 140 | client.connect() 141 | await removeAllTriggers(client); 142 | 143 | for (const table of tableList) { 144 | 145 | if (table.hookEnabled) { 146 | await createTriggerForTable(client, table.table, "notify_trigger") 147 | } 148 | } 149 | 150 | const result = await getAllTablesWithTriggers(client); 151 | client.end(); 152 | return result; 153 | } 154 | 155 | module.exports = router; -------------------------------------------------------------------------------- /client/src/components/commands/CommandCreateEdit.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 68 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /client/src/views/Hooks.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 199 | -------------------------------------------------------------------------------- /client/src/views/Watcher.vue: -------------------------------------------------------------------------------- 1 | 118 | 119 | 120 | 247 | 248 | -------------------------------------------------------------------------------- /server/src/pg-error-codes.ts: -------------------------------------------------------------------------------- 1 | const errorCodes = new Map([ 2 | ["00000", "successful_completion"], 3 | ["01000", "warning"], 4 | ["0100C", "dynamic_result_sets_returned"], 5 | ["01008", "implicit_zero_bit_padding"], 6 | ["01003", "null_value_eliminated_in_set_function"], 7 | ["01007", "privilege_not_granted"], 8 | ["01006", "privilege_not_revoked"], 9 | ["01004", "string_data_right_truncation"], 10 | ["01P01", "deprecated_feature"], 11 | ["02000", "no_data"], 12 | ["02001", "no_additional_dynamic_result_sets_returned"], 13 | ["03000", "sql_statement_not_yet_complete"], 14 | ["08000", "connection_exception"], 15 | ["08003", "connection_does_not_exist"], 16 | ["08006", "connection_failure"], 17 | ["08001", "sqlclient_unable_to_establish_sqlconnection"], 18 | ["08004", "sqlserver_rejected_establishment_of_sqlconnection"], 19 | ["08007", "transaction_resolution_unknown"], 20 | ["08P01", "protocol_violation"], 21 | ["09000", "triggered_action_exception"], 22 | ["0A000", "feature_not_supported"], 23 | ["0B000", "invalid_transaction_initiation"], 24 | ["0F000", "locator_exception"], 25 | ["0F001", "invalid_locator_specification"], 26 | ["0L000", "invalid_grantor"], 27 | ["0LP01", "invalid_grant_operation"], 28 | ["0P000", "invalid_role_specification"], 29 | ["0Z000", "diagnostics_exception"], 30 | ["0Z002", "stacked_diagnostics_accessed_without_active_handler"], 31 | ["20000", "case_not_found"], 32 | ["21000", "cardinality_violation"], 33 | ["22000", "data_exception"], 34 | ["2202E", "array_subscript_error"], 35 | ["22021", "character_not_in_repertoire"], 36 | ["22008", "datetime_field_overflow"], 37 | ["22012", "division_by_zero"], 38 | ["22005", "error_in_assignment"], 39 | ["2200B", "escape_character_conflict"], 40 | ["22022", "indicator_overflow"], 41 | ["22015", "interval_field_overflow"], 42 | ["2201E", "invalid_argument_for_logarithm"], 43 | ["22014", "invalid_argument_for_ntile_function"], 44 | ["22016", "invalid_argument_for_nth_value_function"], 45 | ["2201F", "invalid_argument_for_power_function"], 46 | ["2201G", "invalid_argument_for_width_bucket_function"], 47 | ["22018", "invalid_character_value_for_cast"], 48 | ["22007", "invalid_datetime_format"], 49 | ["22019", "invalid_escape_character"], 50 | ["2200D", "invalid_escape_octet"], 51 | ["22025", "invalid_escape_sequence"], 52 | ["22P06", "nonstandard_use_of_escape_character"], 53 | ["22010", "invalid_indicator_parameter_value"], 54 | ["22023", "invalid_parameter_value"], 55 | ["2201B", "invalid_regular_expression"], 56 | ["2201W", "invalid_row_count_in_limit_clause"], 57 | ["2201X", "invalid_row_count_in_result_offset_clause"], 58 | ["2202H", "invalid_tablesample_argument"], 59 | ["2202G", "invalid_tablesample_repeat"], 60 | ["22009", "invalid_time_zone_displacement_value"], 61 | ["2200C", "invalid_use_of_escape_character"], 62 | ["2200G", "most_specific_type_mismatch"], 63 | ["22004", "null_value_not_allowed"], 64 | ["22002", "null_value_no_indicator_parameter"], 65 | ["22003", "numeric_value_out_of_range"], 66 | ["2200H", "sequence_generator_limit_exceeded"], 67 | ["22026", "string_data_length_mismatch"], 68 | ["22001", "string_data_right_truncation"], 69 | ["22011", "substring_error"], 70 | ["22027", "trim_error"], 71 | ["22024", "unterminated_c_string"], 72 | ["2200F", "zero_length_character_string"], 73 | ["22P01", "floating_point_exception"], 74 | ["22P02", "invalid_text_representation"], 75 | ["22P03", "invalid_binary_representation"], 76 | ["22P04", "bad_copy_file_format"], 77 | ["22P05", "untranslatable_character"], 78 | ["2200L", "not_an_xml_document"], 79 | ["2200M", "invalid_xml_document"], 80 | ["2200N", "invalid_xml_content"], 81 | ["2200S", "invalid_xml_comment"], 82 | ["2200T", "invalid_xml_processing_instruction"], 83 | ["23000", "integrity_constraint_violation"], 84 | ["23001", "restrict_violation"], 85 | ["23502", "not_null_violation"], 86 | ["23503", "foreign_key_violation"], 87 | ["23505", "unique_violation"], 88 | ["23514", "check_violation"], 89 | ["23P01", "exclusion_violation"], 90 | ["24000", "invalid_cursor_state"], 91 | ["25000", "invalid_transaction_state"], 92 | ["25001", "active_sql_transaction"], 93 | ["25002", "branch_transaction_already_active"], 94 | ["25008", "held_cursor_requires_same_isolation_level"], 95 | ["25003", "inappropriate_access_mode_for_branch_transaction"], 96 | ["25004", "inappropriate_isolation_level_for_branch_transaction"], 97 | ["25005", "no_active_sql_transaction_for_branch_transaction"], 98 | ["25006", "read_only_sql_transaction"], 99 | ["25007", "schema_and_data_statement_mixing_not_supported"], 100 | ["25P01", "no_active_sql_transaction"], 101 | ["25P02", "in_failed_sql_transaction"], 102 | ["25P03", "idle_in_transaction_session_timeout"], 103 | ["26000", "invalid_sql_statement_name"], 104 | ["27000", "triggered_data_change_violation"], 105 | ["28000", "invalid_authorization_specification"], 106 | ["28P01", "invalid_password"], 107 | ["2B000", "dependent_privilege_descriptors_still_exist"], 108 | ["2BP01", "dependent_objects_still_exist"], 109 | ["2D000", "invalid_transaction_termination"], 110 | ["2F000", "sql_routine_exception"], 111 | ["2F005", "function_executed_no_return_statement"], 112 | ["2F002", "modifying_sql_data_not_permitted"], 113 | ["2F003", "prohibited_sql_statement_attempted"], 114 | ["2F004", "reading_sql_data_not_permitted"], 115 | ["34000", "invalid_cursor_name"], 116 | ["38000", "external_routine_exception"], 117 | ["38001", "containing_sql_not_permitted"], 118 | ["38002", "modifying_sql_data_not_permitted"], 119 | ["38003", "prohibited_sql_statement_attempted"], 120 | ["38004", "reading_sql_data_not_permitted"], 121 | ["39000", "external_routine_invocation_exception"], 122 | ["39001", "invalid_sqlstate_returned"], 123 | ["39004", "null_value_not_allowed"], 124 | ["39P01", "trigger_protocol_violated"], 125 | ["39P02", "srf_protocol_violated"], 126 | ["39P03", "event_trigger_protocol_violated"], 127 | ["3B000", "savepoint_exception"], 128 | ["3B001", "invalid_savepoint_specification"], 129 | ["3D000", "invalid_catalog_name"], 130 | ["3F000", "invalid_schema_name"], 131 | ["40000", "transaction_rollback"], 132 | ["40002", "transaction_integrity_constraint_violation"], 133 | ["40001", "serialization_failure"], 134 | ["40003", "statement_completion_unknown"], 135 | ["40P01", "deadlock_detected"], 136 | ["42000", "syntax_error_or_access_rule_violation"], 137 | ["42601", "syntax_error"], 138 | ["42501", "insufficient_privilege"], 139 | ["42846", "cannot_coerce"], 140 | ["42803", "grouping_error"], 141 | ["42P20", "windowing_error"], 142 | ["42P19", "invalid_recursion"], 143 | ["42830", "invalid_foreign_key"], 144 | ["42602", "invalid_name"], 145 | ["42622", "name_too_long"], 146 | ["42939", "reserved_name"], 147 | ["42804", "datatype_mismatch"], 148 | ["42P18", "indeterminate_datatype"], 149 | ["42P21", "collation_mismatch"], 150 | ["42P22", "indeterminate_collation"], 151 | ["42809", "wrong_object_type"], 152 | ["428C9", "generated_always"], 153 | ["42703", "undefined_column"], 154 | ["42883", "undefined_function"], 155 | ["42P01", "undefined_table"], 156 | ["42P02", "undefined_parameter"], 157 | ["42704", "undefined_object"], 158 | ["42701", "duplicate_column"], 159 | ["42P03", "duplicate_cursor"], 160 | ["42P04", "duplicate_database"], 161 | ["42723", "duplicate_function"], 162 | ["42P05", "duplicate_prepared_statement"], 163 | ["42P06", "duplicate_schema"], 164 | ["42P07", "duplicate_table"], 165 | ["42712", "duplicate_alias"], 166 | ["42710", "duplicate_object"], 167 | ["42702", "ambiguous_column"], 168 | ["42725", "ambiguous_function"], 169 | ["42P08", "ambiguous_parameter"], 170 | ["42P09", "ambiguous_alias"], 171 | ["42P10", "invalid_column_reference"], 172 | ["42611", "invalid_column_definition"], 173 | ["42P11", "invalid_cursor_definition"], 174 | ["42P12", "invalid_database_definition"], 175 | ["42P13", "invalid_function_definition"], 176 | ["42P14", "invalid_prepared_statement_definition"], 177 | ["42P15", "invalid_schema_definition"], 178 | ["42P16", "invalid_table_definition"], 179 | ["42P17", "invalid_object_definition"], 180 | ["44000", "with_check_option_violation"], 181 | ["53000", "insufficient_resources"], 182 | ["53100", "disk_full"], 183 | ["53200", "out_of_memory"], 184 | ["53300", "too_many_connections"], 185 | ["53400", "configuration_limit_exceeded"], 186 | ["54000", "program_limit_exceeded"], 187 | ["54001", "statement_too_complex"], 188 | ["54011", "too_many_columns"], 189 | ["54023", "too_many_arguments"], 190 | ["55000", "object_not_in_prerequisite_state"], 191 | ["55006", "object_in_use"], 192 | ["55P02", "cant_change_runtime_param"], 193 | ["55P03", "lock_not_available"], 194 | ["57000", "operator_intervention"], 195 | ["57014", "query_canceled"], 196 | ["57P01", "admin_shutdown"], 197 | ["57P02", "crash_shutdown"], 198 | ["57P03", "cannot_connect_now"], 199 | ["57P04", "database_dropped"], 200 | ["58000", "system_error"], 201 | ["58030", "io_error"], 202 | ["58P01", "undefined_file"], 203 | ["58P02", "duplicate_file"], 204 | ["72000", "snapshot_too_old"], 205 | ["F0000", "config_file_error"], 206 | ["F0001", "lock_file_exists"], 207 | ["HV000", "fdw_error"], 208 | ["HV005", "fdw_column_name_not_found"], 209 | ["HV002", "fdw_dynamic_parameter_value_needed"], 210 | ["HV010", "fdw_function_sequence_error"], 211 | ["HV021", "fdw_inconsistent_descriptor_information"], 212 | ["HV024", "fdw_invalid_attribute_value"], 213 | ["HV007", "fdw_invalid_column_name"], 214 | ["HV008", "fdw_invalid_column_number"], 215 | ["HV004", "fdw_invalid_data_type"], 216 | ["HV006", "fdw_invalid_data_type_descriptors"], 217 | ["HV091", "fdw_invalid_descriptor_field_identifier"], 218 | ["HV00B", "fdw_invalid_handle"], 219 | ["HV00C", "fdw_invalid_option_index"], 220 | ["HV00D", "fdw_invalid_option_name"], 221 | ["HV090", "fdw_invalid_string_length_or_buffer_length"], 222 | ["HV00A", "fdw_invalid_string_format"], 223 | ["HV009", "fdw_invalid_use_of_null_pointer"], 224 | ["HV014", "fdw_too_many_handles"], 225 | ["HV001", "fdw_out_of_memory"], 226 | ["HV00P", "fdw_no_schemas"], 227 | ["HV00J", "fdw_option_name_not_found"], 228 | ["HV00K", "fdw_reply_handle"], 229 | ["HV00Q", "fdw_schema_not_found"], 230 | ["HV00R", "fdw_table_not_found"], 231 | ["HV00L", "fdw_unable_to_create_execution"], 232 | ["HV00M", "fdw_unable_to_create_reply"], 233 | ["HV00N", "fdw_unable_to_establish_connection"], 234 | ["P0000", "plpgsql_error"], 235 | ["P0001", "raise_exception"], 236 | ["P0002", "no_data_found"], 237 | ["P0003", "too_many_rows"], 238 | ["P0004", "assert_failure"], 239 | ["XX000", "internal_error"], 240 | ["XX001", "data_corrupted"], 241 | ["XX002", "index_corrupted"] 242 | ]); 243 | 244 | export function getErrorByCode(code: string) { 245 | return errorCodes.get(code); 246 | } --------------------------------------------------------------------------------