├── 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 |
2 | {{ event.action }}
3 |
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 |
2 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/client/src/components/misc/ButtonContainer.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
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 |
2 |
8 |
9 | LOW
10 |
11 |
12 |
13 | MEDIUM
14 |
15 |
16 |
17 | HIGH
18 |
19 |
20 |
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 |
2 |
3 |
5 | Darkmode
6 |
11 |
12 |
13 |
14 |
15 |
16 |
25 |
26 |
--------------------------------------------------------------------------------
/client/src/components/misc/DropSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
17 |
18 |
19 | No data
20 |
21 |
22 |
23 |
24 |
25 |
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 |
2 |
3 |
4 |
5 | Config
6 |
7 | mdi-cloud-download
8 |
9 |
10 |
11 |
12 |
13 | Config
14 |
15 | mdi-cloud-upload
16 |
17 |
18 |
19 |
20 |
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 |
2 | {{ indicatorText }}
3 |
4 |
5 |
--------------------------------------------------------------------------------
/client/src/components/connection/ConnectionIndicator.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 | Connections
12 |
16 | {{numberActiveConnections}}
17 |
18 |
19 |
20 |
26 | Backend
27 |
31 | {{$store.getters.websocketStatus > 0 ? 'mdi-server-plus' : 'mdi-server-minus'}}
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/client/src/components/commands/CommandTable.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 | | ID |
11 | Name |
12 | Severity |
13 | Actions |
14 |
15 |
16 |
17 |
18 | | {{ command.id }} |
19 | {{ command.name }} |
20 |
21 | {{ command.severity }}
22 | |
23 |
24 |
29 | |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/client/src/components/commands/CommandOutput.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 | Command output
10 |
11 |
15 |
21 | {{ commandData.status }}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
37 |
38 |
39 | |
40 | {{object[0]}}
41 | |
42 |
43 |
44 |
45 |
49 | |
53 | {{object[1]}}
54 | |
55 |
56 |
57 |
58 |
59 |
60 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/client/src/components/info-modals/ConnectionInfoModal.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | mdi-information-outline
7 |
8 |
9 |
10 |
11 |
12 |
13 | Connection Info
14 |
15 |
16 | Close
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | mdi-play-circle-outline
26 |
27 |
28 |
29 | Activate a connection. Starts listening to events. Only available if connection is inactive.
30 |
31 |
32 |
33 |
34 |
35 |
36 | mdi-pause-circle-outline
37 |
38 |
39 |
40 | Deactivate a connection. End listening to events. Only available if connection is active.
41 |
42 |
43 |
44 |
45 |
46 |
47 | mdi-pencil-circle-outline
48 |
49 |
50 |
51 | Edit a connection. Only available if connection is inactive.
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
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 | 
53 |
54 | Hooks: Define which database tables should be watched by the application
55 |
56 | 
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 | 
63 |
64 | Watcher view with whole object expanded.
65 |
66 | 
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 |
2 |
3 |
7 |
8 |
13 | mdi-cog-outline
14 |
15 |
16 |
17 |
18 |
19 | Settings Watcher
20 |
21 |
22 |
23 |
24 |
25 |
32 |
33 |
34 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
54 | Abort
55 |
56 |
61 | Save
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/client/src/views/Connections.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
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 |
2 |
3 |
4 |
5 |
6 | mdi-play-circle-outline
7 |
8 |
13 |
14 |
15 | mdi-pencil-circle-outline
16 |
17 |
18 |
19 |
20 | mdi-delete-circle-outline
21 |
22 |
23 |
24 |
25 |
26 |
{{alertMessage}}
34 |
35 |
36 |
37 |
103 |
104 |
105 |
--------------------------------------------------------------------------------
/client/src/views/Commands.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
18 |
19 |
20 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
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 |
2 |
3 |
4 |
5 |
6 |
7 | mdi-play-circle-outline
8 |
9 |
10 | mdi-pause-circle-outline
11 |
12 |
13 | mdi-pencil-circle-outline
14 |
15 |
16 |
17 |
18 |
19 | Edit connection
20 |
21 |
22 |
23 |
24 | Save
25 | Delete
26 |
27 | Close
28 |
29 |
30 |
31 | Invalid connection properties. Try again with changed properties.
32 |
33 |
34 |
35 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/client/src/components/connection/CreateDatabaseConnection.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | mdi-playlist-plus
7 |
8 |
9 |
10 |
11 | Create connection
12 |
13 |
14 |
19 |
20 |
21 |
30 | Check{{ status }}
31 |
32 |
33 |
39 | Create
40 | Abort
46 |
47 |
48 |
49 | Invalid connection properties. Try again with changed properties.
50 |
51 | Connection valid.
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/client/src/components/watcher/ObjectDiff.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | |
7 | Key
8 | |
9 |
10 | OldValue
11 | |
12 |
13 | NewValue
14 |
15 |
16 |
17 |
29 | {{(wholeObject) ? 'mdi-minus' : 'mdi-plus' }}
30 |
31 |
32 | {{(wholeObject) ? 'Only differences' : 'Whole object' }}
33 |
34 | |
35 |
36 |
37 |
38 |
39 |
40 | | No difference |
44 |
45 |
46 |
52 | | {{ diffPr.key }} |
53 | {{ String(diffPr.old) }} |
54 | {{ String(diffPr.new) }} |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/client/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ currentIcon }}
7 |
8 |
9 | {{
10 | $route.name || "pgtools"
11 | }}
12 |
13 |
14 |
15 |
16 | mdi-cog
17 |
18 |
19 |
20 |
21 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | pgtools
30 |
31 |
32 | mdi-github
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | Settings
56 |
57 |
58 |
59 | mdi-close
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
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 |
2 |
3 |
7 |
8 |
9 |
10 |
11 | mdi-playlist-plus
12 |
13 |
14 |
15 |
16 |
17 |
18 | {{(mode ==='CREATE') ? 'Create command' : 'Edit command'}}
19 |
20 |
21 |
22 |
23 |
24 |
32 |
33 |
34 |
35 |
36 |
37 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | {{(mode === 'CREATE') ? 'Create' : 'Update'}}
56 | Abort
60 |
61 |
62 |
63 |
Invalid connection properties. Try again with changed properties.
64 |
65 |
66 |
67 |
68 |
152 |
153 |
154 |
--------------------------------------------------------------------------------
/client/src/views/Hooks.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | No active connection. Go to Connections and create at least one
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
34 |
35 | mdi-content-save-outline
36 |
37 |
38 |
43 |
44 | mdi-reload
45 |
46 |
47 |
48 |
49 |
50 |
63 |
64 |
68 |
69 |
70 |
71 |
72 | No active connection. Go to Connections and activate at least one
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
199 |
--------------------------------------------------------------------------------
/client/src/views/Watcher.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
15 |
16 |
17 |
24 |
25 |
26 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
44 | mdi-filter-remove-outline
45 |
46 |
47 | Reset filter
48 |
49 |
50 |
51 |
52 |
53 |
58 | mdi-delete-sweep-outline
59 |
60 |
61 | Clear events
62 |
63 |
64 |
65 |
66 |
67 |
84 |
85 |
86 |
87 |
88 | {{formatTimestamp(item.timestamp)}}
89 |
90 |
91 | {{ item.database }}
92 |
93 |
94 | {{ item.table }}
95 |
96 |
97 | {{ item.id }}
98 |
99 |
100 |
101 |
102 |
107 |
108 | |
109 |
110 |
111 |
112 |
113 |
114 | Invalid filter. Database and table are required. Automatically resetting to event mode now.
115 |
116 |
117 |
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 | }
--------------------------------------------------------------------------------