├── .github
└── FUNDING.yml
├── LICENSE
├── README.md
├── build.sh
├── client
├── .env.example
├── .gitignore
├── README.md
├── bun.lock
├── components.json
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ └── vite.svg
├── src
│ ├── App.css
│ ├── App.tsx
│ ├── assets
│ │ └── react.svg
│ ├── atoms
│ │ └── user.tsx
│ ├── components
│ │ ├── app-sidebar.tsx
│ │ ├── login-form.tsx
│ │ ├── nav-main.tsx
│ │ ├── nav-projects.tsx
│ │ ├── nav-user.tsx
│ │ ├── team-switcher.tsx
│ │ ├── theme-provider.tsx
│ │ ├── tooltip-warper.tsx
│ │ └── ui
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── breadcrumb.tsx
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── chart.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── form.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── select.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── sheet.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── skeleton.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── toggle.tsx
│ │ │ └── tooltip.tsx
│ ├── forms
│ │ └── alert
│ │ │ ├── alert.tsx
│ │ │ └── schema.ts
│ ├── hooks
│ │ └── use-mobile.tsx
│ ├── index.css
│ ├── lib
│ │ ├── api.ts
│ │ └── utils.ts
│ ├── main.tsx
│ ├── models
│ │ └── alert.ts
│ ├── pages
│ │ ├── auth
│ │ │ └── login.tsx
│ │ ├── layout
│ │ │ ├── DashbordLayout.tsx
│ │ │ └── root.tsx
│ │ └── nodes
│ │ │ ├── components
│ │ │ ├── alert-card.tsx
│ │ │ ├── alert-form.tsx
│ │ │ ├── alert-tab.tsx
│ │ │ ├── animate-dot.tsx
│ │ │ ├── button-bar.tsx
│ │ │ ├── cpu-chart.tsx
│ │ │ ├── memory-chart.tsx
│ │ │ ├── metrics-tab.tsx
│ │ │ ├── name-edit-dialog.tsx
│ │ │ ├── netwrok-chart.tsx
│ │ │ ├── node-card.tsx
│ │ │ └── node-view-tabs.tsx
│ │ │ ├── index.tsx
│ │ │ └── view.tsx
│ ├── router
│ │ ├── protectedRoute.tsx
│ │ └── router.tsx
│ ├── schema
│ │ └── node.ts
│ ├── types
│ │ └── node_type.ts
│ └── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── config.xrun.json
├── docs
└── readme_draft.md
├── server
├── .air.toml
├── .env.example
├── .gitignore
├── cmd
│ ├── app
│ │ └── app.go
│ └── cli
│ │ └── cli.go
├── go.mod
├── go.sum
├── internal
│ ├── db
│ │ ├── alerts.sql.go
│ │ ├── custom_queries.go
│ │ ├── db.go
│ │ ├── models.go
│ │ ├── net_stat.sql.go
│ │ ├── node.sql.go
│ │ ├── repo.go
│ │ ├── sql
│ │ │ ├── migrations
│ │ │ │ ├── 20250202111257_user_table.down.sql
│ │ │ │ ├── 20250202111257_user_table.up.sql
│ │ │ │ ├── 20250202120019_node_table.down.sql
│ │ │ │ ├── 20250202120019_node_table.up.sql
│ │ │ │ ├── 20250203171934_node_sys_info_table.down.sql
│ │ │ │ ├── 20250203171934_node_sys_info_table.up.sql
│ │ │ │ ├── 20250203172228_node_disk_info_table.down.sql
│ │ │ │ ├── 20250203172228_node_disk_info_table.up.sql
│ │ │ │ ├── 20250207145808_system_stats_table.down.sql
│ │ │ │ ├── 20250207145808_system_stats_table.up.sql
│ │ │ │ ├── 20250215115558_enable_tablefunc_extension.down.sql
│ │ │ │ ├── 20250215115558_enable_tablefunc_extension.up.sql
│ │ │ │ ├── 20250216175622_system_stat_retention_policy.down.sql
│ │ │ │ ├── 20250216175622_system_stat_retention_policy.up.sql
│ │ │ │ ├── 20250216180054_net_stats.down.sql
│ │ │ │ ├── 20250216180054_net_stats.up.sql
│ │ │ │ ├── 20250216180502_net_stat_retention_policy.down.sql
│ │ │ │ ├── 20250216180502_net_stat_retention_policy.up.sql
│ │ │ │ ├── 20250228161056_alert_table.down.sql
│ │ │ │ └── 20250228161056_alert_table.up.sql
│ │ │ └── query
│ │ │ │ ├── alerts.sql
│ │ │ │ ├── net_stat.sql
│ │ │ │ ├── node.sql
│ │ │ │ ├── system_stat.sql
│ │ │ │ └── user.sql
│ │ ├── system_stat.sql.go
│ │ └── user.sql.go
│ ├── dto
│ │ ├── alert_dto.go
│ │ ├── node_dto.go
│ │ └── user_dto.go
│ ├── handlers
│ │ ├── alert_handler.go
│ │ ├── auth_handler.go
│ │ └── node_handler.go
│ ├── middleware
│ │ ├── auth.go
│ │ └── cors.go
│ ├── services
│ │ ├── alert_service.go
│ │ ├── node_service.go
│ │ └── user_service.go
│ ├── tcpserver
│ │ ├── actions.go
│ │ ├── alert_backgroud_service.go
│ │ ├── alert_sender.go
│ │ ├── server.go
│ │ └── types.go
│ └── utils
│ │ ├── bytecovertor.go
│ │ ├── password.go
│ │ └── token.go
├── main.go
├── makefile
├── sqlc.yml
└── test
│ └── custom_query_test.go
└── tmp
└── build-errors.log
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanda0/vps_pilot/33277623bbfd8bd3b88364d4108eb5ad712b4501/.github/FUNDING.yml
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Sandakelum
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # VPS Pilot
2 |
3 | VPS Pilot is a **server monitoring and management platform** designed for private VPS servers.
4 | It provides real-time monitoring, alerting, project management, and (future) cron job automation — all from a single dashboard.
5 |
6 | 
7 |
8 |
9 |
10 | ## ✨ Features
11 |
12 | ### 📊 Monitoring
13 | - Agents installed on each node (server). (Agent repo: https://github.com/sanda0/vps_pilot_agent)
14 | - Agents send system metrics to the central server:
15 | - **CPU usage**
16 | - **Memory usage**
17 | - **Network statistics**
18 | - Metrics are visualized in the dashboard with selectable time ranges:
19 | - 5 minutes, 15 minutes, 1 hour, 1 day, 2 days, 7 days.
20 | ---
21 |
22 | ### 🚨 Alerting
23 | - Users can configure alerts based on metric thresholds.
24 | - Notifications sent via:
25 | - **Discord** (✅ Implemented)
26 | - **Email** (✅ Implemented)
27 | - **Slack** (✅ Implemented)
28 |
29 | ---
30 |
31 | ### 🚀 Projects Management (TODO)
32 | - Each node can have multiple projects.
33 | - Projects require a `config.vpspilot.json` file.
34 | - Agents scan disks for project config files and send project metadata to the central server.
35 | - Central server will display available projects and allow:
36 | - Running predefined commands.
37 | - Managing project logs.
38 | - Backing up project directories and databases.
39 |
40 | **Sample `config.vpspilot.json`:**
41 | ```json
42 | {
43 | "name": "meta ads dashboard",
44 | "tech": ["laravel", "react", "mysql"],
45 | "logs": [],
46 | "commands": [
47 | { "name": "node build", "command": "npm run build" },
48 | { "name": "php build", "command": "composer install" }
49 | ],
50 | "backups": {
51 | "env_file": ".env",
52 | "zip_file_name": "project_backup",
53 | "database": {
54 | "connection": "DB_CONNECTION",
55 | "host": "DB_HOST",
56 | "port": "DB_PORT",
57 | "username": "DB_USERNAME",
58 | "password": "DB_PASSWORD",
59 | "database_name": "DB_DATABASE"
60 | },
61 | "dir": [
62 | "storage/app",
63 | "database/companies"
64 | ]
65 | }
66 | }
67 | ```
68 | > **Note:** This feature is still under development.
69 |
70 | ---
71 |
72 | ### ⏲️ Cron Jobs Management (TODO)
73 | - Plan to allow users to create and manage cron jobs on nodes remotely from the dashboard.
74 | - Feature is not implemented yet.
75 |
76 | ---
77 |
78 | ## 🛠️ Tech Stack
79 |
80 | | Component | Technology |
81 | |------------------|------------|
82 | | Agent | Golang |
83 | | Central Server | Golang |
84 | | Dashboard | React.js |
85 | | Database | TimescaleDB |
86 |
87 | ---
88 |
89 | ## ⚙️ Configuration
90 |
91 | ### Email Alerts
92 | Configure the following environment variables in your `.env` file for email notifications:
93 |
94 | ```env
95 | MAIL_HOST="your-smtp-host.com"
96 | MAIL_PORT=465
97 | MAIL_USERNAME="your-email@domain.com"
98 | MAIL_PASSWORD="your-email-password"
99 | MAIL_FROM_ADDRESS="noreply@domain.com"
100 | ```
101 |
102 | ### Slack Alerts
103 | For Slack notifications, configure webhook URLs per alert in the dashboard. To create a Slack webhook:
104 | 1. Go to your Slack workspace
105 | 2. Navigate to Apps → Incoming Webhooks
106 | 3. Create a new webhook for your desired channel
107 | 4. Copy the webhook URL and paste it in the alert configuration
108 |
109 | ### Discord Alerts
110 | For Discord notifications, configure webhook URLs per alert in the dashboard. To create a Discord webhook:
111 | 1. Go to your Discord server settings
112 | 2. Navigate to Integrations → Webhooks
113 | 3. Create a new webhook for your desired channel
114 | 4. Copy the webhook URL and paste it in the alert configuration
115 |
116 | ---
117 |
118 | ## 📦 Installation
119 |
120 | (Instructions will be added soon. Likely via Docker Compose or manual Go/React build.)
121 |
122 | ---
123 |
124 | ## 📅 Roadmap
125 |
126 | - [x] Real-time metrics collection (CPU, Memory, Network)
127 | - [x] Discord alert integration
128 | - [x] Email alert integration
129 | - [x] Slack alert integration
130 | - [ ] Project management via `config.vpspilot.json`
131 | - [ ] Remote command execution for projects
132 | - [ ] Project backups (database + directories)
133 | - [ ] Remote cron job creation and management
134 |
135 | ---
136 |
137 | ## 🧑💻 Author
138 |
139 | Made with ❤️ by [Sandakelum](https://github.com/sanda0)
140 |
141 | ---
142 |
143 | ## 📜 License
144 |
145 | This project is licensed under the [MIT License](LICENSE).
146 |
147 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # VPS Pilot Build Script
4 | # This script builds the React UI and embeds it into the Go binary
5 |
6 | set -e
7 |
8 | echo "🏗️ Building VPS Pilot..."
9 |
10 | # Colors for output
11 | RED='\033[0;31m'
12 | GREEN='\033[0;32m'
13 | YELLOW='\033[1;33m'
14 | NC='\033[0m' # No Color
15 |
16 | # Check if we're in the right directory
17 | if [ ! -f "build.sh" ]; then
18 | echo -e "${RED}❌ Error: Please run this script from the project root directory${NC}"
19 | exit 1
20 | fi
21 |
22 | # Step 1: Build the React UI
23 | echo -e "${YELLOW}📦 Building React UI...${NC}"
24 | cd client
25 |
26 | # Check if node_modules exists, if not install dependencies
27 | if [ ! -d "node_modules" ]; then
28 | echo -e "${YELLOW}📥 Installing Node.js dependencies...${NC}"
29 | if command -v bun &> /dev/null; then
30 | bun install
31 | elif command -v npm &> /dev/null; then
32 | npm install
33 | else
34 | echo -e "${RED}❌ Error: Neither bun nor npm found. Please install Node.js and bun/npm.${NC}"
35 | exit 1
36 | fi
37 | fi
38 |
39 | # Build the React app
40 | echo -e "${YELLOW}⚛️ Building React application...${NC}"
41 | if command -v bun &> /dev/null; then
42 | bun run build
43 | elif command -v npm &> /dev/null; then
44 | npm run build
45 | else
46 | echo -e "${RED}❌ Error: Neither bun nor npm found.${NC}"
47 | exit 1
48 | fi
49 |
50 | if [ ! -d "dist" ]; then
51 | echo -e "${RED}❌ Error: React build failed - dist directory not found${NC}"
52 | exit 1
53 | fi
54 |
55 | echo -e "${GREEN}✅ React UI built successfully${NC}"
56 |
57 | # Step 2: Copy built UI to server directory
58 | echo -e "${YELLOW}📁 Copying UI files to server...${NC}"
59 | cd ../server
60 |
61 | # Remove old embedded files if they exist
62 | rm -rf web/dist
63 | mkdir -p web
64 |
65 | # Copy the built React app
66 | cp -r ../client/dist web/
67 |
68 | echo -e "${GREEN}✅ UI files copied successfully${NC}"
69 |
70 | # Step 3: Build the Go server with embedded UI
71 | echo -e "${YELLOW}🔧 Building Go server with embedded UI...${NC}"
72 |
73 | # Build the Go binary
74 | go build -ldflags "-s -w" -o vps-pilot .
75 |
76 | if [ ! -f "vps-pilot" ]; then
77 | echo -e "${RED}❌ Error: Go build failed${NC}"
78 | exit 1
79 | fi
80 |
81 | echo -e "${GREEN}✅ Go server built successfully${NC}"
82 |
83 | # Step 4: Display success message
84 | echo ""
85 | echo -e "${GREEN}🎉 Build completed successfully!${NC}"
86 | echo -e "${GREEN}📁 Binary location: server/vps-pilot${NC}"
87 | echo ""
88 | echo -e "${YELLOW}📋 Usage:${NC}"
89 | echo " cd server"
90 | echo " ./vps-pilot --help"
91 | echo ""
92 | echo -e "${YELLOW}🚀 To run the server:${NC}"
93 | echo " cd server"
94 | echo " ./vps-pilot"
95 | echo ""
96 |
--------------------------------------------------------------------------------
/client/.env.example:
--------------------------------------------------------------------------------
1 | BASE_URL="http://127.0.0.1:8000/api/v1"
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | .env
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | export default tseslint.config({
18 | languageOptions: {
19 | // other options...
20 | parserOptions: {
21 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
22 | tsconfigRootDir: import.meta.dirname,
23 | },
24 | },
25 | })
26 | ```
27 |
28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
29 | - Optionally add `...tseslint.configs.stylisticTypeChecked`
30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
31 |
32 | ```js
33 | // eslint.config.js
34 | import react from 'eslint-plugin-react'
35 |
36 | export default tseslint.config({
37 | // Set the react version
38 | settings: { react: { version: '18.3' } },
39 | plugins: {
40 | // Add the react plugin
41 | react,
42 | },
43 | rules: {
44 | // other rules...
45 | // Enable its recommended rules
46 | ...react.configs.recommended.rules,
47 | ...react.configs['jsx-runtime'].rules,
48 | },
49 | })
50 | ```
51 |
--------------------------------------------------------------------------------
/client/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/client/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview",
11 | "addcn": "bun x --bun shadcn@latest add $1"
12 | },
13 | "dependencies": {
14 | "@hookform/resolvers": "^4.1.2",
15 | "@radix-ui/react-alert-dialog": "^1.1.6",
16 | "@radix-ui/react-avatar": "^1.1.2",
17 | "@radix-ui/react-collapsible": "^1.1.2",
18 | "@radix-ui/react-dialog": "^1.1.5",
19 | "@radix-ui/react-dropdown-menu": "^2.1.5",
20 | "@radix-ui/react-label": "^2.1.2",
21 | "@radix-ui/react-popover": "^1.1.5",
22 | "@radix-ui/react-select": "^2.1.6",
23 | "@radix-ui/react-separator": "^1.1.1",
24 | "@radix-ui/react-slot": "^1.1.2",
25 | "@radix-ui/react-switch": "^1.1.3",
26 | "@radix-ui/react-tabs": "^1.1.3",
27 | "@radix-ui/react-toggle": "^1.1.2",
28 | "@radix-ui/react-tooltip": "^1.1.8",
29 | "axios": "^1.7.9",
30 | "class-variance-authority": "^0.7.1",
31 | "clsx": "^2.1.1",
32 | "jotai": "^2.11.3",
33 | "lucide-react": "^0.474.0",
34 | "react": "^18.3.1",
35 | "react-dom": "^18.3.1",
36 | "react-hook-form": "^7.54.2",
37 | "react-router": "^7.1.3",
38 | "react-social-icons": "^6.22.0",
39 | "recharts": "^2.15.1",
40 | "tailwind-merge": "^2.6.0",
41 | "tailwindcss-animate": "^1.0.7",
42 | "zod": "^3.24.2"
43 | },
44 | "devDependencies": {
45 | "@eslint/js": "^9.17.0",
46 | "@types/node": "^22.10.10",
47 | "@types/react": "^18.3.18",
48 | "@types/react-dom": "^18.3.5",
49 | "@vitejs/plugin-react-swc": "^3.5.0",
50 | "autoprefixer": "^10.4.20",
51 | "eslint": "^9.17.0",
52 | "eslint-plugin-react-hooks": "^5.0.0",
53 | "eslint-plugin-react-refresh": "^0.4.16",
54 | "globals": "^15.14.0",
55 | "postcss": "^8.5.1",
56 | "tailwindcss": "3.4.17",
57 | "typescript": "~5.6.2",
58 | "typescript-eslint": "^8.18.2",
59 | "vite": "^6.0.5"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/client/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { RouterProvider } from "react-router";
2 | import router from "./router/router";
3 | import { ThemeProvider } from "./components/theme-provider";
4 |
5 | function App() {
6 |
7 | return (
8 | <>
9 |
10 |
11 |
12 | >
13 | )
14 | }
15 |
16 | export default App
17 |
--------------------------------------------------------------------------------
/client/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/atoms/user.tsx:
--------------------------------------------------------------------------------
1 | import {atom} from 'jotai'
2 |
3 | export interface User {
4 | id: number;
5 | email: string;
6 | username: string;
7 | }
8 |
9 | export const userAtom = atom(null)
--------------------------------------------------------------------------------
/client/src/components/app-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import {
3 |
4 | GalleryVerticalEnd,
5 | Globe,
6 |
7 | ReplaceAll,
8 | Server,
9 | Settings2,
10 | } from "lucide-react"
11 |
12 | import { NavMain } from "@/components/nav-main"
13 | import { NavUser } from "@/components/nav-user"
14 |
15 | import {
16 | Sidebar,
17 | SidebarContent,
18 | SidebarFooter,
19 | SidebarHeader,
20 | SidebarMenuButton,
21 | SidebarRail,
22 | } from "@/components/ui/sidebar"
23 | import { userAtom, User } from "@/atoms/user"
24 | import { useAtom } from "jotai"
25 |
26 | // This is sample data.
27 | const data = {
28 | user: {
29 | name: "shadcn",
30 | email: "m@example.com",
31 | avatar: "/avatars/shadcn.jpg",
32 | },
33 | company:
34 | {
35 | name: "VPS Pilot",
36 | logo: GalleryVerticalEnd,
37 | plan: "Enterprise",
38 | }
39 | ,
40 | navMain: [
41 | {
42 | title: "Nodes",
43 | url: "/nodes",
44 | icon: Server,
45 | },
46 | {
47 | title: "Projects",
48 | url: "#",
49 | icon: Globe
50 | },
51 | {
52 | title: "Cron Jobs",
53 | url: "#",
54 | icon: ReplaceAll
55 | },
56 | {
57 | title: "Settings",
58 | url: "#",
59 | icon: Settings2,
60 | items: [
61 | {
62 | title: "Users",
63 | url: "#",
64 | }
65 | ],
66 | },
67 | ],
68 | }
69 |
70 | export function AppSidebar({ ...props }: React.ComponentProps) {
71 |
72 | const [user] = useAtom(userAtom)
73 | data.user.email = user?.email || data.user.email
74 | data.user.name = user?.username || data.user.name
75 |
76 |
77 |
78 | return (
79 |
80 |
81 |
85 |
86 |
87 |
88 |
89 |
90 | {data.company.name}
91 |
92 | {data.company.plan}
93 |
94 |
95 |
96 |
97 |
98 |
99 | {/* */}
100 |
101 |
102 |
103 |
104 |
105 |
106 | )
107 | }
108 |
--------------------------------------------------------------------------------
/client/src/components/login-form.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import { Button } from "@/components/ui/button"
3 | import {
4 | Card,
5 | CardContent,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card"
9 | import { Input } from "@/components/ui/input"
10 | import { Label } from "@/components/ui/label"
11 | import { useState } from "react"
12 | import api from "@/lib/api"
13 | import { useNavigate } from "react-router"
14 | import { useAtom } from "jotai"
15 | import { userAtom, User } from "@/atoms/user"
16 |
17 |
18 | export function LoginForm({
19 | className,
20 | ...props
21 | }: React.ComponentPropsWithoutRef<"div">) {
22 |
23 | const [email, setEmail] = useState("")
24 | const [password, setPassword] = useState("")
25 | const [isLoading, setIsLoading] = useState(false)
26 | const [error, setError] = useState("")
27 | const navigate = useNavigate()
28 |
29 | const [_, setUserAtom] = useAtom(userAtom)
30 |
31 | const handleSubmit = async (e: React.FormEvent) => {
32 | e.preventDefault()
33 | setIsLoading(true)
34 | setError("")
35 |
36 | api.post("/auth/login", { email, password }).then((res) => {
37 | if (res.status === 200) {
38 | setIsLoading(false)
39 | setError("")
40 | setUserAtom({
41 | id: res.data.data.id,
42 | email: res.data.data.email,
43 | username: res.data.data.username
44 | })
45 |
46 | navigate("/")
47 | } else if (res.status === 401) {
48 | setIsLoading(false)
49 | setError("Invalid email or password")
50 | }
51 | }).catch((err) => {
52 | console.error(err)
53 | setIsLoading(false)
54 | setError("An error occurred")
55 | })
56 | }
57 |
58 | return (
59 |
60 |
61 |
62 | Welcome back
63 |
64 |
65 |
107 |
108 |
109 | {/*
*/}
113 |
114 | )
115 | }
116 |
--------------------------------------------------------------------------------
/client/src/components/nav-main.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { ChevronRight, type LucideIcon } from "lucide-react"
4 |
5 | import {
6 | Collapsible,
7 | CollapsibleContent,
8 | CollapsibleTrigger,
9 | } from "@/components/ui/collapsible"
10 | import {
11 | SidebarGroup,
12 | SidebarGroupLabel,
13 | SidebarMenu,
14 | SidebarMenuButton,
15 | SidebarMenuItem,
16 | SidebarMenuSub,
17 | SidebarMenuSubButton,
18 | SidebarMenuSubItem,
19 | } from "@/components/ui/sidebar"
20 | import { Link } from "react-router"
21 |
22 | export function NavMain({
23 | items,
24 | }: {
25 | items: {
26 | title: string
27 | url: string
28 | icon?: LucideIcon
29 | isActive?: boolean
30 | items?: {
31 | title: string
32 | url: string
33 | }[]
34 | }[]
35 | }) {
36 | return (
37 |
38 | Platform
39 |
40 | {items.map((item) => (
41 |
42 | item.items ?
43 |
49 |
50 |
51 |
52 | {item.icon && }
53 | {item.title}
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | {item.items?.map((subItem) => (
62 |
63 |
64 |
65 | {subItem.title}
66 |
67 |
68 |
69 | ))}
70 |
71 |
72 |
73 | :
74 |
75 |
76 |
77 |
78 | {item.icon && }
79 |
80 |
81 | {item.title}
82 |
83 |
84 |
85 |
86 |
87 |
88 | ))}
89 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/client/src/components/nav-projects.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Folder,
3 | Forward,
4 | MoreHorizontal,
5 | Trash2,
6 | type LucideIcon,
7 | } from "lucide-react"
8 |
9 | import {
10 | DropdownMenu,
11 | DropdownMenuContent,
12 | DropdownMenuItem,
13 | DropdownMenuSeparator,
14 | DropdownMenuTrigger,
15 | } from "@/components/ui/dropdown-menu"
16 | import {
17 | SidebarGroup,
18 | SidebarGroupLabel,
19 | SidebarMenu,
20 | SidebarMenuAction,
21 | SidebarMenuButton,
22 | SidebarMenuItem,
23 | useSidebar,
24 | } from "@/components/ui/sidebar"
25 |
26 | export function NavProjects({
27 | projects,
28 | }: {
29 | projects: {
30 | name: string
31 | url: string
32 | icon: LucideIcon
33 | }[]
34 | }) {
35 | const { isMobile } = useSidebar()
36 |
37 | return (
38 |
39 | Projects
40 |
41 | {projects.map((item) => (
42 |
43 |
44 |
45 |
46 | {item.name}
47 |
48 |
49 |
50 |
51 |
52 |
53 | More
54 |
55 |
56 |
61 |
62 |
63 | View Project
64 |
65 |
66 |
67 | Share Project
68 |
69 |
70 |
71 |
72 | Delete Project
73 |
74 |
75 |
76 |
77 | ))}
78 |
79 |
80 |
81 | More
82 |
83 |
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/client/src/components/nav-user.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | BadgeCheck,
5 | Bell,
6 | ChevronsUpDown,
7 | LogOut,
8 | } from "lucide-react"
9 |
10 | import {
11 | Avatar,
12 | AvatarFallback,
13 | AvatarImage,
14 | } from "@/components/ui/avatar"
15 | import {
16 | DropdownMenu,
17 | DropdownMenuContent,
18 | DropdownMenuGroup,
19 | DropdownMenuItem,
20 | DropdownMenuLabel,
21 | DropdownMenuSeparator,
22 | DropdownMenuTrigger,
23 | } from "@/components/ui/dropdown-menu"
24 | import {
25 | SidebarMenu,
26 | SidebarMenuButton,
27 | SidebarMenuItem,
28 | useSidebar,
29 | } from "@/components/ui/sidebar"
30 |
31 | export function NavUser({
32 | user,
33 | }: {
34 | user: {
35 | name: string
36 | email: string
37 | avatar: string
38 | }
39 | }) {
40 | const { isMobile } = useSidebar()
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
51 |
52 |
53 | CN
54 |
55 |
56 | {user.name}
57 | {user.email}
58 |
59 |
60 |
61 |
62 |
68 |
69 |
70 |
71 |
72 | CN
73 |
74 |
75 | {user.name}
76 | {user.email}
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | Account
85 |
86 |
87 |
88 |
89 | Notifications
90 |
91 |
92 |
93 |
94 |
95 | Log out
96 |
97 |
98 |
99 |
100 |
101 | )
102 | }
103 |
--------------------------------------------------------------------------------
/client/src/components/team-switcher.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { ChevronsUpDown, Plus } from "lucide-react"
3 |
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuLabel,
9 | DropdownMenuSeparator,
10 | DropdownMenuShortcut,
11 | DropdownMenuTrigger,
12 | } from "@/components/ui/dropdown-menu"
13 | import {
14 | SidebarMenu,
15 | SidebarMenuButton,
16 | SidebarMenuItem,
17 | useSidebar,
18 | } from "@/components/ui/sidebar"
19 |
20 | export function TeamSwitcher({
21 | teams,
22 | }: {
23 | teams: {
24 | name: string
25 | logo: React.ElementType
26 | plan: string
27 | }[]
28 | }) {
29 | const { isMobile } = useSidebar()
30 | const [activeTeam, setActiveTeam] = React.useState(teams[0])
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
41 |
44 |
45 |
46 | {activeTeam.name}
47 |
48 | {activeTeam.plan}
49 |
50 |
51 |
52 |
53 |
59 |
60 | Teams
61 |
62 | {teams.map((team, index) => (
63 | setActiveTeam(team)}
66 | className="gap-2 p-2"
67 | >
68 |
69 |
70 |
71 | {team.name}
72 | ⌘{index + 1}
73 |
74 | ))}
75 |
76 |
77 |
80 | Add team
81 |
82 |
83 |
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/client/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from "react"
2 |
3 | type Theme = "dark" | "light" | "system"
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode
7 | defaultTheme?: Theme
8 | storageKey?: string
9 | }
10 |
11 | type ThemeProviderState = {
12 | theme: Theme
13 | setTheme: (theme: Theme) => void
14 | }
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: "system",
18 | setTheme: () => null,
19 | }
20 |
21 | const ThemeProviderContext = createContext(initialState)
22 |
23 | export function ThemeProvider({
24 | children,
25 | defaultTheme = "system",
26 | storageKey = "vite-ui-theme",
27 | ...props
28 | }: ThemeProviderProps) {
29 | const [theme, setTheme] = useState(
30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
31 | )
32 |
33 | useEffect(() => {
34 | const root = window.document.documentElement
35 |
36 | root.classList.remove("light", "dark")
37 |
38 | if (theme === "system") {
39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
40 | .matches
41 | ? "dark"
42 | : "light"
43 |
44 | root.classList.add(systemTheme)
45 | return
46 | }
47 |
48 | root.classList.add(theme)
49 | }, [theme])
50 |
51 | const value = {
52 | theme,
53 | setTheme: (theme: Theme) => {
54 | localStorage.setItem(storageKey, theme)
55 | setTheme(theme)
56 | },
57 | }
58 |
59 | return (
60 |
61 | {children}
62 |
63 | )
64 | }
65 |
66 | export const useTheme = () => {
67 | const context = useContext(ThemeProviderContext)
68 |
69 | if (context === undefined)
70 | throw new Error("useTheme must be used within a ThemeProvider")
71 |
72 | return context
73 | }
74 |
--------------------------------------------------------------------------------
/client/src/components/tooltip-warper.tsx:
--------------------------------------------------------------------------------
1 | import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
2 |
3 | export function TooltipWarper({ children, tooltipContent }: { children: React.ReactNode, tooltipContent: React.ReactNode }) {
4 | return <>
5 |
6 |
7 |
8 | {children}
9 |
10 |
11 | {tooltipContent}
12 |
13 |
14 |
15 | >
16 | }
--------------------------------------------------------------------------------
/client/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { buttonVariants } from "@/components/ui/button"
6 |
7 | const AlertDialog = AlertDialogPrimitive.Root
8 |
9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
10 |
11 | const AlertDialogPortal = AlertDialogPrimitive.Portal
12 |
13 | const AlertDialogOverlay = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, ...props }, ref) => (
17 |
25 | ))
26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
27 |
28 | const AlertDialogContent = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
33 |
34 |
42 |
43 | ))
44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
45 |
46 | const AlertDialogHeader = ({
47 | className,
48 | ...props
49 | }: React.HTMLAttributes) => (
50 |
57 | )
58 | AlertDialogHeader.displayName = "AlertDialogHeader"
59 |
60 | const AlertDialogFooter = ({
61 | className,
62 | ...props
63 | }: React.HTMLAttributes) => (
64 |
71 | )
72 | AlertDialogFooter.displayName = "AlertDialogFooter"
73 |
74 | const AlertDialogTitle = React.forwardRef<
75 | React.ElementRef,
76 | React.ComponentPropsWithoutRef
77 | >(({ className, ...props }, ref) => (
78 |
83 | ))
84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
85 |
86 | const AlertDialogDescription = React.forwardRef<
87 | React.ElementRef,
88 | React.ComponentPropsWithoutRef
89 | >(({ className, ...props }, ref) => (
90 |
95 | ))
96 | AlertDialogDescription.displayName =
97 | AlertDialogPrimitive.Description.displayName
98 |
99 | const AlertDialogAction = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
110 |
111 | const AlertDialogCancel = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
124 | ))
125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
126 |
127 | export {
128 | AlertDialog,
129 | AlertDialogPortal,
130 | AlertDialogOverlay,
131 | AlertDialogTrigger,
132 | AlertDialogContent,
133 | AlertDialogHeader,
134 | AlertDialogFooter,
135 | AlertDialogTitle,
136 | AlertDialogDescription,
137 | AlertDialogAction,
138 | AlertDialogCancel,
139 | }
140 |
--------------------------------------------------------------------------------
/client/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ))
19 | Avatar.displayName = AvatarPrimitive.Root.displayName
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ))
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ))
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
47 |
48 | export { Avatar, AvatarImage, AvatarFallback }
49 |
--------------------------------------------------------------------------------
/client/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/client/src/components/ui/breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { ChevronRight, MoreHorizontal } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Breadcrumb = React.forwardRef<
8 | HTMLElement,
9 | React.ComponentPropsWithoutRef<"nav"> & {
10 | separator?: React.ReactNode
11 | }
12 | >(({ ...props }, ref) => )
13 | Breadcrumb.displayName = "Breadcrumb"
14 |
15 | const BreadcrumbList = React.forwardRef<
16 | HTMLOListElement,
17 | React.ComponentPropsWithoutRef<"ol">
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | BreadcrumbList.displayName = "BreadcrumbList"
29 |
30 | const BreadcrumbItem = React.forwardRef<
31 | HTMLLIElement,
32 | React.ComponentPropsWithoutRef<"li">
33 | >(({ className, ...props }, ref) => (
34 |
39 | ))
40 | BreadcrumbItem.displayName = "BreadcrumbItem"
41 |
42 | const BreadcrumbLink = React.forwardRef<
43 | HTMLAnchorElement,
44 | React.ComponentPropsWithoutRef<"a"> & {
45 | asChild?: boolean
46 | }
47 | >(({ asChild, className, ...props }, ref) => {
48 | const Comp = asChild ? Slot : "a"
49 |
50 | return (
51 |
56 | )
57 | })
58 | BreadcrumbLink.displayName = "BreadcrumbLink"
59 |
60 | const BreadcrumbPage = React.forwardRef<
61 | HTMLSpanElement,
62 | React.ComponentPropsWithoutRef<"span">
63 | >(({ className, ...props }, ref) => (
64 |
72 | ))
73 | BreadcrumbPage.displayName = "BreadcrumbPage"
74 |
75 | const BreadcrumbSeparator = ({
76 | children,
77 | className,
78 | ...props
79 | }: React.ComponentProps<"li">) => (
80 | svg]:w-3.5 [&>svg]:h-3.5", className)}
84 | {...props}
85 | >
86 | {children ?? }
87 |
88 | )
89 | BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
90 |
91 | const BreadcrumbEllipsis = ({
92 | className,
93 | ...props
94 | }: React.ComponentProps<"span">) => (
95 |
101 |
102 | More
103 |
104 | )
105 | BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
106 |
107 | export {
108 | Breadcrumb,
109 | BreadcrumbList,
110 | BreadcrumbItem,
111 | BreadcrumbLink,
112 | BreadcrumbPage,
113 | BreadcrumbSeparator,
114 | BreadcrumbEllipsis,
115 | }
116 |
--------------------------------------------------------------------------------
/client/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/client/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/client/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
2 |
3 | const Collapsible = CollapsiblePrimitive.Root
4 |
5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
6 |
7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
8 |
9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }
10 |
--------------------------------------------------------------------------------
/client/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { X } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Dialog = DialogPrimitive.Root
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger
10 |
11 | const DialogPortal = DialogPrimitive.Portal
12 |
13 | const DialogClose = DialogPrimitive.Close
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ))
52 | DialogContent.displayName = DialogPrimitive.Content.displayName
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | )
66 | DialogHeader.displayName = "DialogHeader"
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | )
80 | DialogFooter.displayName = "DialogFooter"
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ))
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ))
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogTrigger,
114 | DialogClose,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription,
120 | }
121 |
--------------------------------------------------------------------------------
/client/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message ?? "") : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/client/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/client/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/client/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref
13 | ) => (
14 |
25 | )
26 | )
27 | Separator.displayName = SeparatorPrimitive.Root.displayName
28 |
29 | export { Separator }
30 |
--------------------------------------------------------------------------------
/client/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 |
68 |
69 | Close
70 |
71 | {children}
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/client/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/client/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SwitchPrimitives from "@radix-ui/react-switch"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ))
25 | Switch.displayName = SwitchPrimitives.Root.displayName
26 |
27 | export { Switch }
28 |
--------------------------------------------------------------------------------
/client/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TabsPrimitive from "@radix-ui/react-tabs"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent }
54 |
--------------------------------------------------------------------------------
/client/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TogglePrimitive from "@radix-ui/react-toggle"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const toggleVariants = cva(
8 | "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-transparent",
13 | outline:
14 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
15 | },
16 | size: {
17 | default: "h-9 px-2 min-w-9",
18 | sm: "h-8 px-1.5 min-w-8",
19 | lg: "h-10 px-2.5 min-w-10",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "default",
24 | size: "default",
25 | },
26 | }
27 | )
28 |
29 | const Toggle = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef &
32 | VariantProps
33 | >(({ className, variant, size, ...props }, ref) => (
34 |
39 | ))
40 |
41 | Toggle.displayName = TogglePrimitive.Root.displayName
42 |
43 | export { Toggle, toggleVariants }
44 |
--------------------------------------------------------------------------------
/client/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
17 |
26 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/client/src/forms/alert/schema.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | export interface ActionResponse < T = any > {
4 | success: boolean
5 | message: string
6 | errors ? : {
7 | [K in keyof T] ? : string[]
8 | }
9 | inputs ? : T
10 | }
11 | export const formSchema = z.object({
12 | "id": z.number().optional(),
13 | "node_id": z.number().optional(),
14 | "metric": z.string().min(1),
15 | "threshold": z.number().optional(),
16 | "net_rece_threshold": z.number().optional(),
17 | "net_send_threshold": z.number().optional(),
18 | "duration": z.number().optional(),
19 | "email": z.string().optional(),
20 | "discord": z.string().optional(),
21 | "slack": z.string().optional(),
22 | "enabled": z.boolean().optional()
23 | });
--------------------------------------------------------------------------------
/client/src/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener("change", onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener("change", onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* ... */
6 |
7 | @layer base {
8 | :root {
9 |
10 | --background: 0 0% 100%;
11 |
12 | --foreground: 0 0% 3.9%;
13 |
14 | --card: 0 0% 100%;
15 |
16 | --card-foreground: 0 0% 3.9%;
17 |
18 | --popover: 0 0% 100%;
19 |
20 | --popover-foreground: 0 0% 3.9%;
21 |
22 | --primary: 0 0% 9%;
23 |
24 | --primary-foreground: 0 0% 98%;
25 |
26 | --secondary: 0 0% 96.1%;
27 |
28 | --secondary-foreground: 0 0% 9%;
29 |
30 | --muted: 0 0% 96.1%;
31 |
32 | --muted-foreground: 0 0% 45.1%;
33 |
34 | --accent: 0 0% 96.1%;
35 |
36 | --accent-foreground: 0 0% 9%;
37 |
38 | --destructive: 0 84.2% 60.2%;
39 |
40 | --destructive-foreground: 0 0% 98%;
41 |
42 | --border: 0 0% 89.8%;
43 |
44 | --input: 0 0% 89.8%;
45 |
46 | --ring: 0 0% 3.9%;
47 |
48 | --chart-1: 10 80% 60%;
49 | --chart-2: 15 78% 62%;
50 | --chart-3: 18 76% 58%;
51 | --chart-4: 22 79% 65%;
52 | --chart-5: 25 72% 55%;
53 | --chart-6: 28 74% 60%;
54 | --chart-7: 32 76% 63%;
55 | --chart-8: 35 78% 66%;
56 | --chart-9: 38 75% 58%;
57 | --chart-10: 42 73% 67%;
58 |
59 | --chart-11: 170 60% 38%;
60 | --chart-12: 175 62% 40%;
61 | --chart-13: 180 58% 36%;
62 | --chart-14: 185 65% 42%;
63 | --chart-15: 188 67% 44%;
64 | --chart-16: 192 56% 41%;
65 | --chart-17: 196 59% 37%;
66 | --chart-18: 200 61% 39%;
67 | --chart-19: 204 63% 41%;
68 | --chart-20: 208 60% 38%;
69 |
70 | --chart-21: 195 40% 23%;
71 | --chart-22: 198 43% 26%;
72 | --chart-23: 202 41% 22%;
73 | --chart-24: 206 45% 27%;
74 | --chart-25: 210 48% 29%;
75 | --chart-26: 213 40% 24%;
76 | --chart-27: 216 42% 26%;
77 | --chart-28: 220 44% 28%;
78 | --chart-29: 223 40% 23%;
79 | --chart-30: 226 46% 25%;
80 |
81 | --chart-31: 45 75% 65%;
82 | --chart-32: 50 77% 67%;
83 | --chart-33: 55 71% 62%;
84 | --chart-34: 60 73% 64%;
85 | --chart-35: 65 79% 69%;
86 | --chart-36: 70 75% 65%;
87 | --chart-37: 75 76% 66%;
88 | --chart-38: 80 77% 68%;
89 | --chart-39: 85 73% 63%;
90 | --chart-40: 90 71% 61%;
91 |
92 |
93 | --radius: 0.5rem;
94 |
95 | --sidebar-background: 0 0% 98%;
96 |
97 | --sidebar-foreground: 240 5.3% 26.1%;
98 |
99 | --sidebar-primary: 240 5.9% 10%;
100 |
101 | --sidebar-primary-foreground: 0 0% 98%;
102 |
103 | --sidebar-accent: 240 4.8% 95.9%;
104 |
105 | --sidebar-accent-foreground: 240 5.9% 10%;
106 |
107 | --sidebar-border: 220 13% 91%;
108 |
109 | --sidebar-ring: 217.2 91.2% 59.8%
110 | }
111 |
112 | .dark {
113 |
114 | --background: 0 0% 3.9%;
115 |
116 | --foreground: 0 0% 98%;
117 |
118 | --card: 0 0% 3.9%;
119 |
120 | --card-foreground: 0 0% 98%;
121 |
122 | --popover: 0 0% 3.9%;
123 |
124 | --popover-foreground: 0 0% 98%;
125 |
126 | --primary: 0 0% 98%;
127 |
128 | --primary-foreground: 0 0% 9%;
129 |
130 | --secondary: 0 0% 14.9%;
131 |
132 | --secondary-foreground: 0 0% 98%;
133 |
134 | --muted: 0 0% 14.9%;
135 |
136 | --muted-foreground: 0 0% 63.9%;
137 |
138 | --accent: 0 0% 14.9%;
139 |
140 | --accent-foreground: 0 0% 98%;
141 |
142 | --destructive: 0 62.8% 30.6%;
143 |
144 | --destructive-foreground: 0 0% 98%;
145 |
146 | --border: 0 0% 14.9%;
147 |
148 | --input: 0 0% 14.9%;
149 |
150 | --ring: 0 0% 83.1%;
151 |
152 | --chart-1: 225 72% 49%;
153 | --chart-2: 228 74% 51%;
154 | --chart-3: 231 70% 47%;
155 | --chart-4: 234 77% 53%;
156 | --chart-5: 237 67% 45%;
157 | --chart-6: 240 69% 48%;
158 | --chart-7: 243 71% 50%;
159 | --chart-8: 246 73% 52%;
160 | --chart-9: 249 70% 46%;
161 | --chart-10: 252 76% 54%;
162 |
163 | --chart-11: 155 62% 44%;
164 | --chart-12: 160 65% 46%;
165 | --chart-13: 165 60% 41%;
166 | --chart-14: 170 67% 48%;
167 | --chart-15: 175 69% 49%;
168 | --chart-16: 180 61% 43%;
169 | --chart-17: 185 63% 45%;
170 | --chart-18: 190 64% 47%;
171 | --chart-19: 195 62% 42%;
172 | --chart-20: 200 68% 50%;
173 |
174 | --chart-21: 35 82% 54%;
175 | --chart-22: 38 84% 56%;
176 | --chart-23: 42 80% 52%;
177 | --chart-24: 46 87% 58%;
178 | --chart-25: 50 77% 50%;
179 | --chart-26: 55 79% 53%;
180 | --chart-27: 60 81% 55%;
181 | --chart-28: 65 83% 57%;
182 | --chart-29: 70 80% 51%;
183 | --chart-30: 75 85% 59%;
184 |
185 | --chart-31: 285 67% 58%;
186 | --chart-32: 290 70% 61%;
187 | --chart-33: 295 65% 56%;
188 | --chart-34: 300 72% 63%;
189 | --chart-35: 305 74% 64%;
190 | --chart-36: 310 68% 60%;
191 | --chart-37: 315 69% 62%;
192 | --chart-38: 320 71% 63%;
193 | --chart-39: 325 67% 57%;
194 | --chart-40: 330 73% 65%;
195 |
196 |
197 |
198 |
199 | --sidebar-background: 240 5.9% 10%;
200 |
201 | --sidebar-foreground: 240 4.8% 95.9%;
202 |
203 | --sidebar-primary: 224.3 76.3% 48%;
204 |
205 | --sidebar-primary-foreground: 0 0% 100%;
206 |
207 | --sidebar-accent: 240 3.7% 15.9%;
208 |
209 | --sidebar-accent-foreground: 240 4.8% 95.9%;
210 |
211 | --sidebar-border: 240 3.7% 15.9%;
212 |
213 | --sidebar-ring: 217.2 91.2% 59.8%
214 | }
215 | }
216 |
217 | @layer base {
218 | * {
219 | @apply border-border;
220 | }
221 |
222 | body {
223 | @apply bg-background text-foreground;
224 | }
225 | }
226 |
227 |
228 |
229 | @layer base {
230 | * {
231 | @apply border-border outline-ring/50;
232 | }
233 | body {
234 | @apply bg-background text-foreground;
235 | }
236 | }
--------------------------------------------------------------------------------
/client/src/lib/api.ts:
--------------------------------------------------------------------------------
1 |
2 | import axios from 'axios';
3 |
4 |
5 | const api = axios.create({
6 | baseURL: "http://localhost:8000/api/v1",
7 | headers: {
8 | 'Content-Type': 'application/json',
9 | },
10 | withCredentials: true,
11 | });
12 |
13 |
14 |
15 | export default api;
--------------------------------------------------------------------------------
/client/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/main.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 | //
8 |
9 | // ,
10 | )
11 |
--------------------------------------------------------------------------------
/client/src/models/alert.ts:
--------------------------------------------------------------------------------
1 | export interface Alert {
2 | id: number;
3 | node_id: number;
4 | metric: string;
5 | duration: number;
6 | threshold: Threshold;
7 | net_rece_threshold: Threshold;
8 | net_send_threshold: Threshold;
9 | email: PgString;
10 | discord_webhook: PgString;
11 | slack_webhook: PgString;
12 | is_active: IsActive;
13 | created_at: Date;
14 | updated_at: Date;
15 | }
16 |
17 | export interface PgString {
18 | String: string;
19 | Valid: boolean;
20 | }
21 |
22 | export interface IsActive {
23 | Bool: boolean;
24 | Valid: boolean;
25 | }
26 |
27 | export interface Threshold {
28 | Float64: number;
29 | Valid: boolean;
30 | }
31 |
--------------------------------------------------------------------------------
/client/src/pages/auth/login.tsx:
--------------------------------------------------------------------------------
1 | import { GalleryVerticalEnd } from "lucide-react"
2 |
3 | import { LoginForm } from "@/components/login-form"
4 |
5 | export default function LoginPage() {
6 |
7 | return (
8 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/client/src/pages/layout/DashbordLayout.tsx:
--------------------------------------------------------------------------------
1 | import { AppSidebar } from "@/components/app-sidebar"
2 | import { useTheme } from "@/components/theme-provider"
3 | import { Button } from "@/components/ui/button"
4 | // import {
5 | // Breadcrumb,
6 | // BreadcrumbItem,
7 | // BreadcrumbLink,
8 | // BreadcrumbList,
9 | // BreadcrumbPage,
10 | // BreadcrumbSeparator,
11 | // } from "@/components/ui/breadcrumb"
12 | import { Separator } from "@/components/ui/separator"
13 | import {
14 | SidebarInset,
15 | SidebarProvider,
16 | SidebarTrigger,
17 | } from "@/components/ui/sidebar"
18 | import { Moon, Sun } from "lucide-react"
19 | import { Outlet } from "react-router"
20 |
21 | export default function DashboardLayout() {
22 |
23 |
24 | const { theme, setTheme } = useTheme()
25 |
26 | const toggleTheme = () => {
27 | if (theme === "light") {
28 | setTheme("dark")
29 | } else {
30 | setTheme("light")
31 | }
32 | }
33 |
34 | return (
35 |
36 |
37 |
38 |
62 |
63 |
64 |
65 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/client/src/pages/layout/root.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "react-router";
2 |
3 |
4 | export default function RootLayout() {
5 |
6 | return
7 |
8 | }
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/alert-card.tsx:
--------------------------------------------------------------------------------
1 | import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
2 | import { Badge } from "@/components/ui/badge";
3 | import { Button } from "@/components/ui/button";
4 | import { Card, CardContent } from "@/components/ui/card";
5 | import { Edit, Trash2 } from "lucide-react";
6 | import { SocialIcon } from "react-social-icons";
7 |
8 |
9 | interface AlertCardProps {
10 | id: number;
11 | isEnable: boolean;
12 | matric: string;
13 | value: number;
14 | net_sent: number;
15 | net_recv: number;
16 | email?: string;
17 | discord?: string;
18 | slack?: string;
19 | onEditClick?: (id: number) => void;
20 | onDeleteClick?: (id: number) => void;
21 | }
22 |
23 | export default function AlertCard(props: AlertCardProps) {
24 | return <>
25 |
26 |
27 |
28 |
29 | {props.isEnable ? Active : Inactive}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | Are you absolutely sure?
42 |
43 | This action cannot be undone. This will permanently delete alert.
44 |
45 |
46 |
47 | Cancel
48 | { props.onDeleteClick && props.onDeleteClick(props.id) }} >Continue
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | {props.matric == "cpu" ? "CPU" : null}
58 | {props.matric == "mem" ? "Memory" : null}
59 | {props.matric == "net" ? "Network" : null}
60 |
61 |
{props.matric == "net" ? "Sent: " + props.net_sent + " B/s | Recv: " + props.net_recv + " B/s" : props.value + "%"}
62 |
63 |
64 | {props.email == "" ? null : }
65 | {props.discord == "" ? null : }
66 | {props.slack == "" ? null : }
67 |
68 |
69 |
70 |
71 |
72 |
73 | >
74 | }
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/alert-form.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { Dialog, DialogHeader } from "@/components/ui/dialog";
3 | import { DialogContent, DialogTitle } from "@/components/ui/dialog";
4 | import { AlertFrom } from "@/forms/alert/alert";
5 | import { Alert } from "@/models/alert";
6 |
7 |
8 |
9 | interface AlertFromProps {
10 | open: boolean;
11 | isEdit: boolean;
12 | onOpenChange: (status: boolean) => void;
13 | onSaved?: () => void;
14 | alert?:Alert | null;
15 | }
16 |
17 | export default function AlertFromDialog(props: AlertFromProps) {
18 |
19 |
20 |
21 | return (<>
22 |
33 | >)
34 | }
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/alert-tab.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { useEffect, useState } from "react";
3 | import AlertFrom from "./alert-form";
4 | import { Alert } from "@/models/alert";
5 | import { useParams } from "react-router";
6 | import api from "@/lib/api";
7 | import AlertCard from "./alert-card";
8 |
9 |
10 |
11 | export default function AlertTab() {
12 |
13 | const [openFormDialog, setFromDilaog] = useState(false)
14 | const { id } = useParams<{ id: string }>();
15 | const [alerts, setAlerts] = useState([])
16 | const [currentAlert, setCurrentAlert] = useState()
17 |
18 | const loadAlerts = () => {
19 | api.get(`/alerts?node_id=${id}&limit=100&offset=0`).then((res) => {
20 | setAlerts(res.data.data)
21 | })
22 |
23 | }
24 |
25 | useEffect(() => {
26 | loadAlerts()
27 | }, [])
28 |
29 | useEffect(() => {
30 | loadAlerts()
31 | }, [openFormDialog])
32 |
33 | const onAlertDelete = (id: number) => {
34 | api.delete(`/alerts/${id}`).then(() => {
35 | loadAlerts()
36 | })
37 | }
38 |
39 | const onAlertEdit = (id: number) => {
40 | api.get(`/alerts/${id}`).then((res) => {
41 | setCurrentAlert(res.data.data)
42 | setFromDilaog(true)
43 | console.log(res.data.data)
44 | })
45 |
46 | }
47 |
48 | return <>
49 |
50 |
{ setFromDilaog(e) }} >
51 |
52 |
Alerts
53 |
54 |
58 |
59 |
60 |
61 | {alerts?.map((alert) => {
62 | return
76 | })}
77 |
78 |
79 | >
80 | }
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/animate-dot.tsx:
--------------------------------------------------------------------------------
1 | interface AnimateDotProps {
2 | color: 'success' | 'danger' | 'warning' | 'info';
3 | size: number;
4 | extraClass?: string;
5 | }
6 |
7 | const colorClasses = {
8 | success: 'bg-green-500',
9 | danger: 'bg-red-500',
10 | warning: 'bg-yellow-500',
11 | info: 'bg-blue-500',
12 | };
13 |
14 | export default function AnimateDot({ color, size, extraClass = '' }: AnimateDotProps) {
15 | const colorClass = colorClasses[color] || 'bg-green-500';
16 | const sizeClass = `h-${size} w-${size}`;
17 |
18 | return (
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/button-bar.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { useState } from "react";
3 |
4 |
5 | interface ButtonBarProps {
6 | list: string[];
7 | onClick?: (item: string) => void;
8 | }
9 |
10 | export default function ButtonBar(props: ButtonBarProps) {
11 | const [selected, setSelected] = useState(props.list[0])
12 | const handleOnClick = (item: string) => {
13 | props.onClick && props.onClick(item)
14 | setSelected(item)
15 | }
16 | return <>
17 |
18 | {props.list.map((item, index) => (
19 |
22 | ))}
23 |
24 | >
25 | }
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/cpu-chart.tsx:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
5 |
6 |
7 | import {
8 | ChartConfig,
9 | ChartContainer,
10 | ChartTooltip,
11 | ChartTooltipContent,
12 | } from "@/components/ui/chart"
13 | import { useEffect, useState } from "react"
14 |
15 |
16 |
17 |
18 |
19 | interface ChartProps {
20 | timeRange: string
21 | data: { [key: string]: { time: string; value: number }[] }
22 | cpuCount: number
23 | }
24 |
25 |
26 |
27 |
28 |
29 | export function CpuChart(props: ChartProps) {
30 | const [chartConfig, setChartConfig] = useState({})
31 | const [chartData, setChartData] = useState([])
32 | useEffect(() => {
33 |
34 | // console.log("props.data", props.data)
35 | // console.log("props.timeRange", props.timeRange)
36 | // console.log("props.cpuCount", props.cpuCount);
37 |
38 | const newChartConfig: ChartConfig = {};
39 | const colorGap = Math.floor(40 / props.cpuCount);
40 | for (let i = 1; i <= props.cpuCount; i++) {
41 | newChartConfig[`cpu_${i}`] = {
42 | label: `CPU ${i}`,
43 | color: `hsl(var(--chart-${i * colorGap}))`,
44 | }
45 | }
46 |
47 | setChartConfig(newChartConfig);
48 | console.log("data", props.data);
49 | setChartData(props.data)
50 |
51 | }, [props.data, props.timeRange, props.cpuCount])
52 |
53 | return (
54 |
55 |
56 |
64 |
65 | new Date(value).toLocaleString('en-US', { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
70 | />
71 | `${value}%`}
75 | ticks={[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]}
76 | />
77 | new Date(value).toLocaleString('en-US', {
79 | month: 'short',
80 | day: '2-digit',
81 | hour: '2-digit',
82 | minute: '2-digit',
83 | second: "2-digit",
84 | hour12: false
85 | })}
86 | />} />
87 |
88 | {Object.keys(chartConfig).map((key) => (
89 |
98 | ))}
99 |
100 |
101 |
102 |
103 | )
104 | }
105 |
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/memory-chart.tsx:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
5 |
6 |
7 | import {
8 | ChartConfig,
9 | ChartContainer,
10 | ChartTooltip,
11 | ChartTooltipContent,
12 | } from "@/components/ui/chart"
13 |
14 |
15 |
16 | const chartConfig = {
17 | value: {
18 | label: "Memory",
19 | color: "hsl(var(--chart-1))",
20 | },
21 |
22 | } satisfies ChartConfig
23 |
24 | interface ChartProps {
25 | timeRange: string
26 | data: Object[]
27 |
28 | }
29 |
30 | export function MemoryChart(props: ChartProps) {
31 |
32 |
33 |
34 | return (
35 |
36 |
37 |
45 |
46 | new Date(value).toLocaleString('en-US', { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
54 | />
55 | `${value}%`} // domain={[0, 100]}
59 | // tickCount={10}
60 | // minTickGap={10}
61 | ticks={[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]}
62 | />
63 | new Date(value).toLocaleString('en-US', {
65 | month: 'short',
66 | day: '2-digit',
67 | hour: '2-digit',
68 | minute: '2-digit',
69 | second: "2-digit",
70 | hour12: false
71 | })}
72 | />} />
73 |
81 |
82 |
83 |
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/metrics-tab.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
2 | import ButtonBar from "./button-bar";
3 | import { CpuChart } from "./cpu-chart";
4 | import { useEffect, useState } from "react";
5 | import { MemoryChart } from "./memory-chart";
6 | import { NetworkChart } from "./netwrok-chart";
7 | import { useParams } from "react-router";
8 | import { NodeData } from "@/types/node_type";
9 | import api from "@/lib/api";
10 |
11 | export default function MetricsTab() {
12 |
13 | const [currentTimeRange, setCurrentTimeRange] = useState("5M")
14 | const { id } = useParams<{ id: string }>();
15 | const [memData, setMemData] = useState([]);
16 | const [cpuData, setCpuData] = useState();
17 | const [node, setNode] = useState(null);
18 | const [networkData, setNetworkData] = useState();
19 |
20 | useEffect(() => {
21 |
22 | api.get(`/nodes/${id}`).then((res) => {
23 | setNode(res.data.data)
24 | }).catch((err) => {
25 | console.error(err)
26 | })
27 |
28 | const ws = new WebSocket(`ws://localhost:8000/api/v1/nodes/ws/system-stat`);
29 | let timer:any = null;
30 |
31 | ws.onopen = () => {
32 | console.log('WebSocket connection opened');
33 | ws.send(JSON.stringify({ id: Number(id), time_range: currentTimeRange }));
34 |
35 | // Start timer after WebSocket is open
36 | timer = setInterval(() => {
37 | if (ws.readyState === WebSocket.OPEN) {
38 | ws.send(JSON.stringify({ id: Number(id), time_range: currentTimeRange }));
39 | }
40 | }, 10000);
41 | };
42 |
43 | ws.onmessage = (event) => {
44 | const message = JSON.parse(event.data);
45 |
46 | setMemData(message.mem);
47 | setCpuData(message.cpu);
48 | setNetworkData(message.net);
49 | };
50 |
51 | ws.onerror = (error) => {
52 | console.error('WebSocket error:', error);
53 | };
54 |
55 | ws.onclose = () => {
56 | console.log('WebSocket connection closed');
57 | clearInterval(timer); // Clear timer when connection closes
58 | };
59 |
60 | return () => {
61 | ws.close();
62 | clearInterval(timer);
63 | };
64 | }, [id, currentTimeRange]);
65 |
66 | return <>
67 |
68 |
69 |
Metrics
70 |
71 | setCurrentTimeRange(v)} >
72 |
73 |
74 |
75 |
76 |
77 |
78 | CPU usage
79 |
80 |
81 | {cpuData && }
82 |
83 |
84 |
85 |
86 | Memory usage
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | Network
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | >
107 |
108 | }
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/name-edit-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { DialogHeader, DialogFooter, Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
3 | import { Input } from "@/components/ui/input";
4 | import api from "@/lib/api";
5 | import { Label } from "@radix-ui/react-label";
6 | import { useEffect, useState } from "react";
7 |
8 |
9 | interface NameEditDialogProps {
10 | open: boolean;
11 | onOpenChange: (status: boolean) => void;
12 | name?: string;
13 | id?: number;
14 | onSaved?: () => void;
15 | }
16 |
17 | export function NameEditDialog(props: NameEditDialogProps) {
18 | const [name, setName] = useState(props.name)
19 | // setName(props.name)
20 | useEffect(() => {
21 | setName(props.name)
22 | }, [props.name])
23 |
24 | const handleSave = () => {
25 | api.put('/nodes/change-name', {
26 | id: props.id,
27 | name: name
28 | }).then((res) => {
29 | if (res.status === 200) {
30 | props.onOpenChange(false)
31 | props.onSaved?.()
32 | }
33 | }).catch((err) => {
34 | console.error(err)
35 | })
36 | }
37 |
38 | return <>
39 |
55 | >
56 |
57 | }
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/netwrok-chart.tsx:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
5 |
6 |
7 | import {
8 | ChartConfig,
9 | ChartContainer,
10 | ChartTooltip,
11 | ChartTooltipContent,
12 | } from "@/components/ui/chart"
13 |
14 |
15 | const chartConfig = {
16 | recv: {
17 | label: "Received",
18 | color: "hsl(var(--chart-1))",
19 | },
20 | sent: {
21 | label: "Sent",
22 | color: "hsl(var(--chart-30))",
23 | },
24 | } satisfies ChartConfig
25 |
26 | interface ChartProps {
27 | timeRange: string
28 | data: Object[]
29 | }
30 |
31 | export function NetworkChart(props: ChartProps) {
32 | return (
33 |
34 |
35 |
43 |
44 | new Date(value).toLocaleString('en-US', { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
50 | />
51 | `${value} B/s`}
55 |
56 | />
57 | new Date(value).toLocaleString('en-US', {
59 | month: 'short',
60 | day: '2-digit',
61 | hour: '2-digit',
62 | minute: '2-digit',
63 | second: "2-digit",
64 | hour12: false
65 | })}
66 |
67 | />} />
68 |
76 |
85 |
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/node-card.tsx:
--------------------------------------------------------------------------------
1 | import { Cpu, Edit, HardDriveIcon, MemoryStick, Network, Server, Telescope, TerminalSquare } from "lucide-react";
2 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../components/ui/card";
3 | import { Button } from "../../../components/ui/button";
4 | import { NodeSysInfo } from "@/types/node_type";
5 |
6 |
7 |
8 |
9 | export interface NodeCardProps {
10 | data: NodeSysInfo;
11 | onEdit?: (id: number,name:string) => void;
12 | onView?: (id: number) => void;
13 | }
14 |
15 |
16 |
17 |
18 | export function NodeCard(props: NodeCardProps) {
19 |
20 | return <>
21 |
22 |
23 |
24 |
25 |
26 | {props.data.name}
27 |
28 |
29 |
32 |
35 |
36 |
37 |
38 |
39 |
40 | System information
41 |
42 |
43 |
44 |
45 |
46 |
47 | OS
48 |
49 |
{props.data.platform} {props.data.platform_version} ({props.data.os})
50 |
51 |
52 |
53 |
54 | Kernel Version
55 |
56 |
{props.data.kernel_version}
57 |
58 |
59 |
60 |
61 | CPUs
62 |
63 |
{props.data.cpus}
64 |
65 |
66 |
67 |
68 | Memory
69 |
70 |
{props.data.total_memory.toFixed(2)} GB
71 |
72 |
73 |
74 |
75 | IP Address
76 |
77 |
{props.data.ip}
78 |
79 |
80 |
81 |
82 | {/*
83 | Card Footer
84 | */}
85 |
86 |
87 | >
88 |
89 | }
--------------------------------------------------------------------------------
/client/src/pages/nodes/components/node-view-tabs.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Tabs,
3 | TabsContent,
4 | TabsList,
5 | TabsTrigger,
6 | } from "@/components/ui/tabs"
7 | import MetricsTab from "./metrics-tab"
8 | import AlertTab from "./alert-tab"
9 |
10 |
11 |
12 |
13 | export function NodeViewTabs() {
14 | return (
15 |
16 |
17 | Metrics
18 | Alerts
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/pages/nodes/index.tsx:
--------------------------------------------------------------------------------
1 | import { NodeCard } from "@/pages/nodes/components/node-card";
2 |
3 | import api from "@/lib/api";
4 | import { Server } from "lucide-react";
5 | import { useEffect, useState } from "react";
6 | import { NameEditDialog } from "./components/name-edit-dialog";
7 | import { useNavigate } from "react-router";
8 | import { NodeSysInfo } from "@/types/node_type";
9 |
10 |
11 |
12 | export default function NodesIndex() {
13 |
14 | const [nodes, setNodes] = useState([])
15 | const [currentEditNodeId, setCurrentEditNodeId] = useState(null)
16 | const [currentEditNodeName, setCurrentEditNodeName] = useState(null)
17 | const [openEditDialog, setOpenEditDialog] = useState(false)
18 |
19 | const navigate = useNavigate()
20 |
21 | const loadNodes = () => {
22 | api.get("/nodes", {
23 | params: {
24 | limit: 50,
25 | page: 1,
26 | search: ""
27 | }
28 | }).then((res) => {
29 | if (res.status === 200) {
30 | setNodes(res.data.data)
31 | }
32 | }).catch((err) => {
33 | console.error(err)
34 | })
35 | }
36 |
37 | useEffect(() => {
38 | loadNodes()
39 | }, [])
40 |
41 | const handleView = (id: number) => {
42 | navigate(`/nodes/${id}`)
43 | }
44 |
45 | return (
46 | <>
47 | { setOpenEditDialog(s) }} name={currentEditNodeName ?? undefined} id={currentEditNodeId ?? undefined}>
48 |
54 |
55 |
56 | {nodes.map((node) => (
57 | {
58 | setCurrentEditNodeId(id);
59 | setCurrentEditNodeName(name);
60 | setOpenEditDialog(true);
61 | }}
62 | onView={handleView}
63 | >
64 | ))}
65 |
66 |
67 |
68 |
69 | >
70 | )
71 | }
--------------------------------------------------------------------------------
/client/src/pages/nodes/view.tsx:
--------------------------------------------------------------------------------
1 |
2 |
3 | import api from '@/lib/api';
4 | import { NodeData } from '@/types/node_type';
5 | import { RotateCcw, Server } from 'lucide-react';
6 | import { useEffect, useState } from 'react';
7 | import { useParams } from 'react-router';
8 | import AnimateDot from './components/animate-dot';
9 | import { Button } from '@/components/ui/button';
10 | import { TooltipWarper } from '@/components/tooltip-warper';
11 | import { NodeViewTabs } from './components/node-view-tabs';
12 |
13 | export default function NodeView() {
14 | const { id } = useParams<{ id: string }>();
15 | const [node, setNode] = useState(null);
16 | const [isOnline, setIsOnline] = useState(true);
17 |
18 | useEffect(() => {
19 |
20 | api.get(`/nodes/${id}`).then((res) => {
21 | setNode(res.data.data)
22 | }).catch((err) => {
23 | console.error(err)
24 | })
25 |
26 | setIsOnline(true);
27 |
28 | }, [id])
29 |
30 | return <>
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
{node?.ip}
39 |
40 |
Reboot} >
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | >
50 | }
--------------------------------------------------------------------------------
/client/src/router/protectedRoute.tsx:
--------------------------------------------------------------------------------
1 | import { userAtom,User } from "@/atoms/user";
2 | import api from "@/lib/api"
3 | import { useAtom } from "jotai";
4 | import { useState, useEffect } from "react"
5 | import { Navigate, Outlet } from "react-router"
6 |
7 | export default function ProtectedRoute() {
8 | const [isLogged, setIsLogged] = useState(null); // 🔹 null for "loading" state
9 | const [_, setUserAtom] = useAtom(userAtom)
10 |
11 | useEffect(() => {
12 | api.get("/profile")
13 | .then((res) => {
14 | if (res.status === 200) {
15 | setIsLogged(true);
16 | setUserAtom({
17 | id: res.data.data.id,
18 | email: res.data.data.email,
19 | username: res.data.data.username
20 | });
21 | }
22 | })
23 | .catch(() => {
24 | setIsLogged(false); // 🔹 Set to false if request fails
25 | });
26 | }, []);
27 |
28 | // 🔹 Show loading while checking authentication
29 | if (isLogged === null) {
30 | return Loading...
; // You can replace this with a spinner
31 | }
32 |
33 | return isLogged ? : ;
34 | }
35 |
--------------------------------------------------------------------------------
/client/src/router/router.tsx:
--------------------------------------------------------------------------------
1 | import LoginPage from "@/pages/auth/login";
2 | import DashboardLayout from "@/pages/layout/DashbordLayout";
3 | import RootLayout from "@/pages/layout/root";
4 | import { createBrowserRouter, createRoutesFromElements, Route } from "react-router";
5 | import ProtectedRoute from "./protectedRoute";
6 | import NodesIndex from "@/pages/nodes";
7 | import NodeView from "@/pages/nodes/view";
8 |
9 |
10 | const router = createBrowserRouter(
11 | createRoutesFromElements(
12 | }>
13 | } >
14 |
15 | }>
16 | } >
17 | } >
18 | } >
19 |
20 |
21 |
22 |
23 | )
24 | )
25 |
26 | export default router
--------------------------------------------------------------------------------
/client/src/schema/node.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanda0/vps_pilot/33277623bbfd8bd3b88364d4108eb5ad712b4501/client/src/schema/node.ts
--------------------------------------------------------------------------------
/client/src/types/node_type.ts:
--------------------------------------------------------------------------------
1 |
2 | export interface NodeData {
3 |
4 | id: number;
5 | name: string;
6 | ip: string;
7 | cpus: number;
8 | total_memory: number;
9 | }
10 |
11 | export interface NodeSysInfo {
12 | id: number;
13 | name: string;
14 | ip: string;
15 | os: string;
16 | platform: string;
17 | platform_version: string;
18 | kernel_version: string;
19 | cpus: number;
20 | total_memory: number;
21 | }
--------------------------------------------------------------------------------
/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | darkMode: ["class"],
4 | content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
5 | theme: {
6 | extend: {
7 | borderRadius: {
8 | lg: 'var(--radius)',
9 | md: 'calc(var(--radius) - 2px)',
10 | sm: 'calc(var(--radius) - 4px)'
11 | },
12 | colors: {
13 | background: 'hsl(var(--background))',
14 | foreground: 'hsl(var(--foreground))',
15 | card: {
16 | DEFAULT: 'hsl(var(--card))',
17 | foreground: 'hsl(var(--card-foreground))'
18 | },
19 | popover: {
20 | DEFAULT: 'hsl(var(--popover))',
21 | foreground: 'hsl(var(--popover-foreground))'
22 | },
23 | primary: {
24 | DEFAULT: 'hsl(var(--primary))',
25 | foreground: 'hsl(var(--primary-foreground))'
26 | },
27 | secondary: {
28 | DEFAULT: 'hsl(var(--secondary))',
29 | foreground: 'hsl(var(--secondary-foreground))'
30 | },
31 | muted: {
32 | DEFAULT: 'hsl(var(--muted))',
33 | foreground: 'hsl(var(--muted-foreground))'
34 | },
35 | accent: {
36 | DEFAULT: 'hsl(var(--accent))',
37 | foreground: 'hsl(var(--accent-foreground))'
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))'
42 | },
43 | border: 'hsl(var(--border))',
44 | input: 'hsl(var(--input))',
45 | ring: 'hsl(var(--ring))',
46 | chart: {
47 | '1': 'hsl(var(--chart-1))',
48 | '2': 'hsl(var(--chart-2))',
49 | '3': 'hsl(var(--chart-3))',
50 | '4': 'hsl(var(--chart-4))',
51 | '5': 'hsl(var(--chart-5))'
52 | },
53 | sidebar: {
54 | DEFAULT: 'hsl(var(--sidebar-background))',
55 | foreground: 'hsl(var(--sidebar-foreground))',
56 | primary: 'hsl(var(--sidebar-primary))',
57 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
58 | accent: 'hsl(var(--sidebar-accent))',
59 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
60 | border: 'hsl(var(--sidebar-border))',
61 | ring: 'hsl(var(--sidebar-ring))'
62 | }
63 | }
64 | }
65 | },
66 | plugins: [require("tailwindcss-animate")],
67 | }
68 |
69 |
--------------------------------------------------------------------------------
/client/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": [
7 | "ES2020",
8 | "DOM",
9 | "DOM.Iterable"
10 | ],
11 | "module": "ESNext",
12 | "skipLibCheck": true,
13 | /* Bundler mode */
14 | "moduleResolution": "bundler",
15 | "allowImportingTsExtensions": true,
16 | "isolatedModules": true,
17 | "moduleDetection": "force",
18 | "noEmit": true,
19 | "jsx": "react-jsx",
20 | /* Linting */
21 | "strict": true,
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "noFallthroughCasesInSwitch": true,
25 | "noUncheckedSideEffectImports": true,
26 | "baseUrl": ".",
27 | "paths": {
28 | "@/*": [
29 | "./src/*"
30 | ]
31 | }
32 | },
33 | "include": [
34 | "src"
35 | ]
36 | }
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ],
7 | "compilerOptions": {
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/client/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 | import path from "path"
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | resolve: {
9 | alias: {
10 | "@": path.resolve(__dirname, "./src"),
11 | },
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/config.xrun.json:
--------------------------------------------------------------------------------
1 | {
2 | "Commands": [
3 | {
4 | "Label": "React",
5 | "Color": "cyan",
6 | "CmdStr": "bun dev",
7 | "ExecPath": "./client"
8 | },
9 | {
10 | "Label": "Go",
11 | "Color": "blue",
12 | "CmdStr": "air",
13 | "ExecPath": "./server"
14 | }
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/docs/readme_draft.md:
--------------------------------------------------------------------------------
1 | # vps pilot
2 |
3 | ## Monitoring
4 |
5 | agents install on eact nodes then nodes(servers) send matrix to central dashbord (cpu,memory,network matrix) shown them in charts with 5m,15m,1h,1d,2d,7d time slots
6 |
7 | ## alerting
8 |
9 | give option to config alert to user get notification from email,and discord. how it work 1st user config alert with matrix threshhold point
10 |
11 | ```
12 | id: props.alert?.id,
13 | node_id: Number(id),
14 | metric: props.alert?.metric,
15 | threshold: props.alert?.threshold.Float64,
16 | net_rece_threshold: props.alert?.net_rece_threshold.Float64,
17 | net_send_threshold: props.alert?.net_send_threshold.Float64,
18 | duration: props.alert?.duration,
19 | email: props.alert?.email.String,
20 | discord: props.alert?.discord_webhook.String,
21 | slack: props.alert?.slack_webhook.String,
22 | enabled: props.alert?.is_active.Bool
23 | ```
24 |
25 | slack and mail not implement yet
26 |
27 | ## projects
28 |
29 | i need to creat option manage projects on eact node.
30 | nodes can have multiple project. developers need to add `config.vpspilot.json` file to projects then agent shearch that config files on disk and send that data to central server when developer chage that config file(s) updates data sent to central server
31 |
32 | `config.vpspilot.json` file look like this
33 |
34 | ```json
35 |
36 | {
37 | "name":"meta ads dashboard",
38 | "tech":["laravel","react","mysql"], // optional
39 | "logs":[],//path to project logs folders
40 | "commands":[
41 | {
42 | "name":"node build",
43 | "command":"npm run build"
44 | },
45 | {
46 | "name":"php build",
47 | "command":"composer install"
48 | },
49 | ],//this commands can run from central server (show them in like dropdown and run button)
50 | "backups":{
51 | "env_file": ".env",
52 | "zip_file_name": "project_backup",
53 | "database": {
54 | "connection": "DB_CONNECTION",
55 | "host": "DB_HOST",
56 | "port": "DB_PORT",
57 | "username": "DB_USERNAME",
58 | "password": "DB_PASSWORD",
59 | "database_name": "DB_DATABASE"
60 | },
61 | "dir": [
62 | "storage/app",
63 | "database/companies"
64 | ]
65 | }// using this can backup giving dirs and data base backup
66 | }
67 |
68 | ```
69 |
70 | NOTE: this project part is not implement yet i give my plan add this like todo in read me
71 |
72 | ## cron jobs
73 |
74 | this is option to create cron jobs in nodes from central server not implement yet add this todo also
75 |
76 |
77 |
--------------------------------------------------------------------------------
/server/.air.toml:
--------------------------------------------------------------------------------
1 | root = "."
2 | testdata_dir = "testdata"
3 | tmp_dir = "tmp"
4 |
5 | [build]
6 | args_bin = []
7 | bin = "./tmp/main"
8 | cmd = "go build -o ./tmp/main ."
9 | delay = 1000
10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"]
11 | exclude_file = []
12 | exclude_regex = ["_test.go"]
13 | exclude_unchanged = false
14 | follow_symlink = false
15 | full_bin = ""
16 | include_dir = []
17 | include_ext = ["go", "tpl", "tmpl", "html"]
18 | include_file = []
19 | kill_delay = "0s"
20 | log = "build-errors.log"
21 | poll = false
22 | poll_interval = 0
23 | post_cmd = []
24 | pre_cmd = []
25 | rerun = false
26 | rerun_delay = 500
27 | send_interrupt = false
28 | stop_on_error = false
29 |
30 | [color]
31 | app = ""
32 | build = "yellow"
33 | main = "magenta"
34 | runner = "green"
35 | watcher = "cyan"
36 |
37 | [log]
38 | main_only = false
39 | silent = false
40 | time = false
41 |
42 | [misc]
43 | clean_on_exit = false
44 |
45 | [proxy]
46 | app_port = 0
47 | enabled = false
48 | proxy_port = 0
49 |
50 | [screen]
51 | clear_on_rebuild = false
52 | keep_scroll = true
53 |
--------------------------------------------------------------------------------
/server/.env.example:
--------------------------------------------------------------------------------
1 | TCP_SERVER_PORT=55001
2 |
3 |
4 |
5 | TOKEN_LIFESPAN=1000000
6 | TOKEN_SECRET=""
7 |
8 | DB_USER=
9 | DB_PASSWORD=
10 | DB_HOST=
11 | DB_PORT=5432
12 | DB_NAME=vps_pilot
13 |
14 |
15 | MAIL_HOST=""
16 | MAIL_PORT=
17 | MAIL_USERNAME=""
18 | MAIL_PASSWORD=""
19 | MAIL_FROM_ADDRESS=""
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | tmp
2 | .env
3 |
4 | web
5 |
6 | vps-pilot
--------------------------------------------------------------------------------
/server/cmd/app/app.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/gin-contrib/cors"
8 | "github.com/gin-gonic/gin"
9 | "github.com/sanda0/vps_pilot/internal/db"
10 | "github.com/sanda0/vps_pilot/internal/handlers"
11 | "github.com/sanda0/vps_pilot/internal/middleware"
12 | "github.com/sanda0/vps_pilot/internal/services"
13 | )
14 |
15 | func Run(ctx context.Context, repo *db.Repo, port string) {
16 |
17 | //init services
18 | userService := services.NewUserService(ctx, repo)
19 | nodeService := services.NewNodeService(ctx, repo)
20 | alertService := services.NewAlertService(ctx, repo)
21 |
22 | //init handlers
23 | userHandler := handlers.NewAuthHandler(userService)
24 | nodeHander := handlers.NewNodeHandler(nodeService)
25 | alertHandler := handlers.NewAlertHandler(alertService)
26 |
27 | server := gin.Default()
28 | server.Use(cors.New(cors.Config{
29 | AllowOrigins: []string{"http://localhost:5173"}, // Change to specific domains in production
30 | AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
31 | AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
32 | ExposeHeaders: []string{"Content-Length"},
33 | AllowCredentials: true,
34 | MaxAge: 12 * time.Hour,
35 | }))
36 |
37 | //routes
38 | api := server.Group("/api/v1")
39 | // api.
40 |
41 | //auth routes
42 | auth := api.Group("/auth")
43 | {
44 | auth.POST("/login", userHandler.Login)
45 | }
46 |
47 | //dashboard routes
48 | dashbaord := api.Group("/")
49 | dashbaord.Use(middleware.JwtAuthMiddleware())
50 | {
51 | dashbaord.GET("/profile", userHandler.Profile)
52 | nodes := dashbaord.Group("/nodes")
53 | {
54 | nodes.GET("", nodeHander.GetNodes)
55 | nodes.PUT("/change-name", nodeHander.UpdateName)
56 | nodes.GET("/:id", nodeHander.GetNode)
57 | nodes.GET("/ws/system-stat", nodeHander.SystemStatWSHandler)
58 |
59 | }
60 | alerts := dashbaord.Group("/alerts")
61 | {
62 | alerts.GET("/:id", alertHandler.GetAlert)
63 | alerts.POST("", alertHandler.CreateAlert)
64 | alerts.GET("", alertHandler.GetAlerts)
65 | alerts.PUT("/activate", alertHandler.ActivateAlert)
66 | alerts.PUT("/deactivate", alertHandler.DeactivateAlert)
67 | alerts.DELETE("/:id", alertHandler.DeleteAlert)
68 | alerts.PUT("", alertHandler.UpdateAlert)
69 | }
70 | }
71 |
72 | server.Run(":8000")
73 | }
74 |
--------------------------------------------------------------------------------
/server/cmd/cli/cli.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "os"
8 | "strings"
9 |
10 | "github.com/sanda0/vps_pilot/internal/db"
11 | "github.com/sanda0/vps_pilot/internal/utils"
12 | )
13 |
14 | func CreateSuperuser(ctx context.Context, repo *db.Repo) {
15 |
16 | reader := bufio.NewReader(os.Stdin)
17 |
18 | fmt.Print("Enter email: ")
19 | email, _ := reader.ReadString('\n')
20 |
21 | fmt.Print("Enter password: ")
22 | password, _ := reader.ReadString('\n')
23 |
24 | fmt.Printf("Superuser created with email: %s and password: %s\n", email, password)
25 |
26 | hashedPassword, err := utils.HashString(strings.Trim(password, "\n"))
27 | if err != nil {
28 | fmt.Println("Error hashing password")
29 | return
30 | }
31 |
32 | user, err := repo.Queries.CreateUser(ctx, db.CreateUserParams{
33 | Email: strings.Trim(email, "\n"),
34 | PasswordHash: string(hashedPassword),
35 | Username: "admin",
36 | })
37 | if err != nil {
38 | fmt.Println("Error creating user")
39 | return
40 | }
41 |
42 | fmt.Println("User created with id: ", user.ID)
43 |
44 | }
45 |
46 | func CreateMakeFile() error {
47 |
48 | // Get the database connection parameters from the environment variables or from the command line
49 | dbUser := os.Getenv("DB_USER")
50 | dbPassword := os.Getenv("DB_PASSWORD")
51 | dbHost := os.Getenv("DB_HOST")
52 | dbPort := os.Getenv("DB_PORT")
53 | dbName := os.Getenv("DB_NAME")
54 |
55 | fileContent := `
56 | migration:
57 | @read -p "Enter migration name: " name; \
58 | migrate create -ext sql -dir sql/migrations $$name
59 |
60 | migrate:
61 | migrate -source file://sql/migrations \
62 | -database ` + fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dbUser, dbPassword, dbHost, dbPort, dbName) + ` up
63 |
64 | rollback:
65 | migrate -source file://sql/migrations \
66 | -database ` + fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dbUser, dbPassword, dbHost, dbPort, dbName) + ` down
67 |
68 | drop:
69 | migrate -source file://sql/migrations \
70 | -database ` + fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dbUser, dbPassword, dbHost, dbPort, dbName) + ` drop
71 |
72 | sqlc:
73 | sqlc generate
74 |
75 |
76 | migratef:
77 | @read -p "Enter migration number: " num; \
78 | migrate -source file://sql/migrations \
79 | -database ` + fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dbUser, dbPassword, dbHost, dbPort, dbName) + ` force $$num
80 |
81 | `
82 |
83 | fmt.Println(fileContent)
84 | file, err := os.Create("makefile")
85 | if err != nil {
86 | return err
87 | }
88 | defer file.Close()
89 | bytes := []byte(fileContent)
90 | file.Write(bytes)
91 |
92 | return nil
93 |
94 | }
95 |
--------------------------------------------------------------------------------
/server/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/sanda0/vps_pilot
2 |
3 | go 1.22.2
4 |
5 | require (
6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible
7 | github.com/gin-contrib/cors v1.7.3
8 | github.com/gin-gonic/gin v1.10.0
9 | github.com/gorilla/websocket v1.5.3
10 | github.com/joho/godotenv v1.5.1
11 | github.com/lib/pq v1.10.9
12 | golang.org/x/crypto v0.32.0
13 | )
14 |
15 | require (
16 | github.com/bytedance/sonic v1.12.6 // indirect
17 | github.com/bytedance/sonic/loader v0.2.1 // indirect
18 | github.com/cloudwego/base64x v0.1.4 // indirect
19 | github.com/cloudwego/iasm v0.2.0 // indirect
20 | github.com/gabriel-vasile/mimetype v1.4.7 // indirect
21 | github.com/gin-contrib/sse v0.1.0 // indirect
22 | github.com/go-playground/locales v0.14.1 // indirect
23 | github.com/go-playground/universal-translator v0.18.1 // indirect
24 | github.com/go-playground/validator/v10 v10.23.0 // indirect
25 | github.com/goccy/go-json v0.10.4 // indirect
26 | github.com/json-iterator/go v1.1.12 // indirect
27 | github.com/klauspost/cpuid/v2 v2.2.9 // indirect
28 | github.com/kr/text v0.2.0 // indirect
29 | github.com/leodido/go-urn v1.4.0 // indirect
30 | github.com/mattn/go-isatty v0.0.20 // indirect
31 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
32 | github.com/modern-go/reflect2 v1.0.2 // indirect
33 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect
34 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
35 | github.com/ugorji/go/codec v1.2.12 // indirect
36 | golang.org/x/arch v0.12.0 // indirect
37 | golang.org/x/net v0.33.0 // indirect
38 | golang.org/x/sys v0.29.0 // indirect
39 | golang.org/x/text v0.21.0 // indirect
40 | google.golang.org/protobuf v1.36.1 // indirect
41 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
42 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
43 | gopkg.in/yaml.v3 v3.0.1 // indirect
44 | )
45 |
--------------------------------------------------------------------------------
/server/internal/db/custom_queries.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | )
7 |
8 | type GetCPUStatsParams struct {
9 | NodeID int32 `json:"node_id"`
10 | TimeRange string `json:"time_range"`
11 | CpuCount int32 `json:"cpu_count"`
12 | }
13 |
14 | func (q *Queries) GetCPUStats(ctx context.Context, arg GetCPUStatsParams) ([]map[string]interface{}, error) {
15 |
16 | customColSelect := ""
17 | for i := 1; i <= int(arg.CpuCount); i++ {
18 | if i == int(arg.CpuCount) {
19 | customColSelect += fmt.Sprintf("MAX(COALESCE(cpu_%d, 0)) AS cpu_%d ", i, i)
20 | break
21 | }
22 | customColSelect += fmt.Sprintf("MAX(COALESCE(cpu_%d, 0)) AS cpu_%d, ", i, i)
23 | }
24 |
25 | customColCT := ""
26 | for i := 1; i <= int(arg.CpuCount); i++ {
27 | if i == int(arg.CpuCount) {
28 | customColCT += fmt.Sprintf("cpu_%d FLOAT ", i)
29 | break
30 | }
31 | customColCT += fmt.Sprintf("cpu_%d FLOAT, ", i)
32 | }
33 |
34 | query := `
35 |
36 | SELECT
37 | to_char(time, 'YYYY-MM-DD HH24:MI:SS') AS formatted_time,
38 | %s
39 | FROM crosstab(
40 | $$SELECT time, cpu_id::text, value
41 | FROM system_stats
42 | WHERE node_id = %d AND stat_type = 'cpu'
43 | AND time >= now() - INTERVAL '%s'
44 | ORDER BY 1, 2$$,
45 | $$SELECT DISTINCT cpu_id::text
46 | FROM system_stats
47 | WHERE node_id = %d AND stat_type = 'cpu'
48 | ORDER BY 1$$
49 | ) AS ct(
50 | time TIMESTAMP,
51 | %s
52 | )
53 | GROUP BY formatted_time
54 | ORDER BY formatted_time;
55 | `
56 | query = fmt.Sprintf(query, customColSelect, arg.NodeID, arg.TimeRange, arg.NodeID, customColCT)
57 |
58 | rows, err := q.db.QueryContext(ctx, query)
59 | if err != nil {
60 | return nil, err
61 | }
62 | defer rows.Close()
63 |
64 | var result []map[string]interface{}
65 |
66 | for rows.Next() {
67 | var formattedTime string
68 | cpuData := make([]interface{}, arg.CpuCount)
69 | scanArgs := make([]interface{}, arg.CpuCount+1)
70 | scanArgs[0] = &formattedTime
71 | for i := range cpuData {
72 | scanArgs[i+1] = &cpuData[i]
73 | }
74 |
75 | if err := rows.Scan(scanArgs...); err != nil {
76 | return nil, err
77 | }
78 |
79 | // Create a map and insert time & CPU values dynamically
80 | record := make(map[string]interface{})
81 | record["time"] = formattedTime
82 | for i := 1; i <= int(arg.CpuCount); i++ {
83 | record[fmt.Sprintf("cpu_%d", i)] = cpuData[i-1]
84 | }
85 |
86 | result = append(result, record)
87 | }
88 |
89 | return result, nil
90 | }
91 |
--------------------------------------------------------------------------------
/server/internal/db/models.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.28.0
4 |
5 | package db
6 |
7 | import (
8 | "database/sql"
9 | "time"
10 | )
11 |
12 | type Alert struct {
13 | ID int32 `json:"id"`
14 | NodeID int32 `json:"node_id"`
15 | Metric string `json:"metric"`
16 | Duration int32 `json:"duration"`
17 | Threshold sql.NullFloat64 `json:"threshold"`
18 | NetReceThreshold sql.NullFloat64 `json:"net_rece_threshold"`
19 | NetSendThreshold sql.NullFloat64 `json:"net_send_threshold"`
20 | Email sql.NullString `json:"email"`
21 | DiscordWebhook sql.NullString `json:"discord_webhook"`
22 | SlackWebhook sql.NullString `json:"slack_webhook"`
23 | IsActive sql.NullBool `json:"is_active"`
24 | CreatedAt time.Time `json:"created_at"`
25 | UpdatedAt time.Time `json:"updated_at"`
26 | }
27 |
28 | type NetStat struct {
29 | Time time.Time `json:"time"`
30 | NodeID int32 `json:"node_id"`
31 | Sent int64 `json:"sent"`
32 | Recv int64 `json:"recv"`
33 | }
34 |
35 | type Node struct {
36 | ID int32 `json:"id"`
37 | Name sql.NullString `json:"name"`
38 | Ip string `json:"ip"`
39 | CreatedAt time.Time `json:"created_at"`
40 | UpdatedAt time.Time `json:"updated_at"`
41 | }
42 |
43 | type NodeDiskInfo struct {
44 | ID int32 `json:"id"`
45 | NodeID int32 `json:"node_id"`
46 | Device sql.NullString `json:"device"`
47 | MountPoint sql.NullString `json:"mount_point"`
48 | Fstype sql.NullString `json:"fstype"`
49 | Total sql.NullFloat64 `json:"total"`
50 | Used sql.NullFloat64 `json:"used"`
51 | CreatedAt time.Time `json:"created_at"`
52 | UpdatedAt time.Time `json:"updated_at"`
53 | }
54 |
55 | type NodeSysInfo struct {
56 | ID int32 `json:"id"`
57 | NodeID int32 `json:"node_id"`
58 | Os sql.NullString `json:"os"`
59 | Platform sql.NullString `json:"platform"`
60 | PlatformVersion sql.NullString `json:"platform_version"`
61 | KernelVersion sql.NullString `json:"kernel_version"`
62 | Cpus sql.NullInt32 `json:"cpus"`
63 | TotalMemory sql.NullFloat64 `json:"total_memory"`
64 | CreatedAt time.Time `json:"created_at"`
65 | UpdatedAt time.Time `json:"updated_at"`
66 | }
67 |
68 | type SystemStat struct {
69 | Time time.Time `json:"time"`
70 | NodeID int32 `json:"node_id"`
71 | StatType string `json:"stat_type"`
72 | CpuID int32 `json:"cpu_id"`
73 | Value float64 `json:"value"`
74 | }
75 |
76 | type User struct {
77 | ID int32 `json:"id"`
78 | Username string `json:"username"`
79 | Email string `json:"email"`
80 | PasswordHash string `json:"password_hash"`
81 | CreatedAt sql.NullTime `json:"created_at"`
82 | UpdatedAt sql.NullTime `json:"updated_at"`
83 | }
84 |
--------------------------------------------------------------------------------
/server/internal/db/net_stat.sql.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.28.0
4 | // source: net_stat.sql
5 |
6 | package db
7 |
8 | import (
9 | "context"
10 | "database/sql"
11 | "time"
12 | )
13 |
14 | const getNetStats = `-- name: GetNetStats :many
15 | select time,sent,recv from net_stat ns
16 | where node_id = $1
17 | and time >= now() - ($2||'')::interval
18 | `
19 |
20 | type GetNetStatsParams struct {
21 | NodeID int32 `json:"node_id"`
22 | Column2 sql.NullString `json:"column_2"`
23 | }
24 |
25 | type GetNetStatsRow struct {
26 | Time time.Time `json:"time"`
27 | Sent int64 `json:"sent"`
28 | Recv int64 `json:"recv"`
29 | }
30 |
31 | func (q *Queries) GetNetStats(ctx context.Context, arg GetNetStatsParams) ([]GetNetStatsRow, error) {
32 | rows, err := q.query(ctx, q.getNetStatsStmt, getNetStats, arg.NodeID, arg.Column2)
33 | if err != nil {
34 | return nil, err
35 | }
36 | defer rows.Close()
37 | var items []GetNetStatsRow
38 | for rows.Next() {
39 | var i GetNetStatsRow
40 | if err := rows.Scan(&i.Time, &i.Sent, &i.Recv); err != nil {
41 | return nil, err
42 | }
43 | items = append(items, i)
44 | }
45 | if err := rows.Close(); err != nil {
46 | return nil, err
47 | }
48 | if err := rows.Err(); err != nil {
49 | return nil, err
50 | }
51 | return items, nil
52 | }
53 |
54 | const insertNetStats = `-- name: InsertNetStats :exec
55 | INSERT INTO net_stat (time, node_id, sent, recv) VALUES ($1, $2, $3, $4)
56 | `
57 |
58 | type InsertNetStatsParams struct {
59 | Time time.Time `json:"time"`
60 | NodeID int32 `json:"node_id"`
61 | Sent int64 `json:"sent"`
62 | Recv int64 `json:"recv"`
63 | }
64 |
65 | func (q *Queries) InsertNetStats(ctx context.Context, arg InsertNetStatsParams) error {
66 | _, err := q.exec(ctx, q.insertNetStatsStmt, insertNetStats,
67 | arg.Time,
68 | arg.NodeID,
69 | arg.Sent,
70 | arg.Recv,
71 | )
72 | return err
73 | }
74 |
--------------------------------------------------------------------------------
/server/internal/db/repo.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import "database/sql"
4 |
5 | type Repo struct {
6 | *Queries
7 | DB *sql.DB
8 | }
9 |
10 | func NewRepo(db *sql.DB) *Repo {
11 | return &Repo{DB: db, Queries: New(db)}
12 | }
13 |
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250202111257_user_table.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS users;
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250202111257_user_table.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS users (
2 | id SERIAL PRIMARY KEY,
3 | username VARCHAR(50) UNIQUE NOT NULL,
4 | email VARCHAR(100) UNIQUE NOT NULL,
5 | password_hash VARCHAR(255) NOT NULL,
6 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
7 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
8 | );
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250202120019_node_table.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS nodes;
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250202120019_node_table.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS nodes (
2 | id SERIAL PRIMARY KEY,
3 | name TEXT,
4 | ip TEXT NOT NULL UNIQUE,
5 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
6 | updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
7 | );
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250203171934_node_sys_info_table.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS node_sys_info;
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250203171934_node_sys_info_table.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS node_sys_info (
2 | id SERIAL PRIMARY KEY,
3 | node_id INTEGER NOT NULL,
4 | os TEXT,
5 | platform TEXT,
6 | platform_version TEXT,
7 | kernel_version TEXT,
8 | cpus int,
9 | total_memory float,
10 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
11 | updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
12 | FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE
13 | );
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250203172228_node_disk_info_table.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS node_disk_info;
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250203172228_node_disk_info_table.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS node_disk_info (
2 | id SERIAL PRIMARY KEY,
3 | node_id INTEGER NOT NULL,
4 | device TEXT,
5 | mount_point TEXT,
6 | fstype TEXT,
7 | total float,
8 | used float,
9 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
10 | updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
11 | FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE
12 | );
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250207145808_system_stats_table.down.sql:
--------------------------------------------------------------------------------
1 | DO $$
2 | BEGIN
3 | -- Check if the hypertable exists before attempting to drop it
4 | IF EXISTS (
5 | SELECT 1
6 | FROM timescaledb_information.hypertables
7 | WHERE hypertable_name = 'system_stats'
8 | ) THEN
9 | -- Dropping the hypertable (equivalent to dropping the table)
10 | EXECUTE 'DROP TABLE IF EXISTS system_stats CASCADE';
11 | END IF;
12 | END $$;
13 |
14 | -- Optional: Remove the TimescaleDB extension if it's no longer needed
15 | DROP EXTENSION IF EXISTS timescaledb CASCADE;
16 |
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250207145808_system_stats_table.up.sql:
--------------------------------------------------------------------------------
1 | CREATE EXTENSION IF NOT EXISTS timescaledb;
2 | CREATE TABLE IF NOT EXISTS system_stats(
3 | time TIMESTAMPTZ NOT NULL,
4 | node_id INT NOT NULL,
5 | stat_type TEXT NOT NULL CHECK (stat_type IN ('cpu', 'mem')),
6 | cpu_id INT,
7 | value DOUBLE PRECISION NOT NULL,
8 | PRIMARY KEY (time, node_id, stat_type, cpu_id)
9 | );
10 | DO $$ BEGIN IF NOT EXISTS (
11 | SELECT 1
12 | FROM timescaledb_information.hypertables
13 | WHERE hypertable_name = 'system_stats'
14 | ) THEN PERFORM create_hypertable('system_stats', 'time');
15 | END IF;
16 | END $$;
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250215115558_enable_tablefunc_extension.down.sql:
--------------------------------------------------------------------------------
1 | DROP EXTENSION IF EXISTS tablefunc;
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250215115558_enable_tablefunc_extension.up.sql:
--------------------------------------------------------------------------------
1 | CREATE EXTENSION IF NOT EXISTS tablefunc;
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250216175622_system_stat_retention_policy.down.sql:
--------------------------------------------------------------------------------
1 | SELECT remove_retention_policy('system_stats');
2 |
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250216175622_system_stat_retention_policy.up.sql:
--------------------------------------------------------------------------------
1 | SELECT add_retention_policy('system_stats', INTERVAL '7 days');
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250216180054_net_stats.down.sql:
--------------------------------------------------------------------------------
1 | DO $$
2 | BEGIN
3 | -- Check if the hypertable exists before attempting to drop it
4 | IF EXISTS (
5 | SELECT 1
6 | FROM timescaledb_information.hypertables
7 | WHERE hypertable_name = 'net_stat'
8 | ) THEN
9 | -- Dropping the hypertable (equivalent to dropping the table)
10 | EXECUTE 'DROP TABLE IF EXISTS net_stat CASCADE';
11 | END IF;
12 | END $$;
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250216180054_net_stats.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS net_stat (
2 | time TIMESTAMPTZ NOT NULL,
3 | node_id INT NOT NULL,
4 | sent BIGINT NOT NULL, -- Bytes sent
5 | recv BIGINT NOT NULL, -- Bytes received
6 | PRIMARY KEY (time, node_id)
7 | );
8 |
9 | DO $$
10 | BEGIN
11 | IF NOT EXISTS (
12 | SELECT 1 FROM timescaledb_information.hypertables
13 | WHERE hypertable_name = 'net_stat'
14 | ) THEN
15 | PERFORM create_hypertable('net_stat', 'time');
16 | END IF;
17 | END $$;
18 |
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250216180502_net_stat_retention_policy.down.sql:
--------------------------------------------------------------------------------
1 | SELECT remove_retention_policy('net_stat');
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250216180502_net_stat_retention_policy.up.sql:
--------------------------------------------------------------------------------
1 | SELECT add_retention_policy('net_stat', INTERVAL '7 days');
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250228161056_alert_table.down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS alerts;
--------------------------------------------------------------------------------
/server/internal/db/sql/migrations/20250228161056_alert_table.up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS alerts (
2 | id SERIAL PRIMARY KEY,
3 | node_id INT NOT NULL,
4 | metric TEXT NOT NULL,
5 | duration INT NOT NULL,
6 | threshold float DEFAULT 0,
7 | net_rece_threshold float DEFAULT 0,
8 | net_send_threshold float DEFAULT 0,
9 | email text,
10 | discord_webhook text,
11 | slack_webhook text,
12 | is_active boolean DEFAULT true,
13 | created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
14 | updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
15 | );
--------------------------------------------------------------------------------
/server/internal/db/sql/query/alerts.sql:
--------------------------------------------------------------------------------
1 | -- name: CreateAlert :one
2 | INSERT INTO alerts(
3 | node_id,
4 | metric,
5 | duration,
6 | threshold,
7 | net_rece_threshold,
8 | net_send_threshold,
9 | email,
10 | discord_webhook,
11 | slack_webhook,
12 | is_active
13 | )
14 | values (
15 | $1,
16 | $2,
17 | $3,
18 | $4,
19 | $5,
20 | $6,
21 | $7,
22 | $8,
23 | $9,
24 | $10
25 | )
26 | RETURNING *;
27 |
28 | -- name: GetAlerts :many
29 | SELECT * FROM alerts
30 | WHERE node_id = $1
31 | ORDER BY id DESC
32 | LIMIT $2 OFFSET $3;
33 |
34 | -- name: GetAlert :one
35 | SELECT * FROM alerts
36 | WHERE id = $1;
37 |
38 | -- name: UpdateAlert :one
39 | UPDATE alerts
40 | SET node_id = $2,
41 | metric = $3,
42 | duration = $4,
43 | threshold = $5,
44 | net_rece_threshold = $6,
45 | net_send_threshold = $7,
46 | email = $8,
47 | discord_webhook = $9,
48 | slack_webhook = $10,
49 | is_active = $11
50 | WHERE id = $1
51 | RETURNING *;
52 |
53 | -- name: DeleteAlert :exec
54 | DELETE FROM alerts
55 | WHERE id = $1;
56 |
57 | -- name: ActivateAlert :exec
58 | UPDATE alerts
59 | SET is_active = true
60 | WHERE id = $1;
61 |
62 | -- name: DeactivateAlert :exec
63 | UPDATE alerts
64 | SET is_active = false
65 | WHERE id = $1;
66 |
67 | -- name: GetActiveAlertsByNodeAndMetric :many
68 | SELECT a.*,n.name as node_name,n.ip as node_ip FROM alerts a
69 | join nodes n on a.node_id = n.id
70 | WHERE node_id = $1 AND metric = $2 AND is_active = true;
71 |
--------------------------------------------------------------------------------
/server/internal/db/sql/query/net_stat.sql:
--------------------------------------------------------------------------------
1 | -- name: InsertNetStats :exec
2 | INSERT INTO net_stat (time, node_id, sent, recv) VALUES ($1, $2, $3, $4);
3 |
4 | -- name: GetNetStats :many
5 | select time,sent,recv from net_stat ns
6 | where node_id = $1
7 | and time >= now() - ($2||'')::interval;
--------------------------------------------------------------------------------
/server/internal/db/sql/query/node.sql:
--------------------------------------------------------------------------------
1 | -- name: CreateNode :one
2 | INSERT INTO nodes (name, ip)
3 | VALUES ($1, $2)
4 | RETURNING *;
5 | -- name: GetNode :one
6 | SELECT *
7 | FROM nodes
8 | WHERE id = $1;
9 | -- name: GetNodes :many
10 | SELECT *
11 | FROM nodes
12 | LIMIT $1 OFFSET $2;
13 | -- name: UpdateNode :one
14 | UPDATE nodes
15 | SET name = $2,
16 | ip = $3,
17 | updated_at = CURRENT_TIMESTAMP
18 | WHERE id = $1
19 | RETURNING *;
20 | -- name: DeleteNode :execrows
21 | DELETE FROM nodes
22 | WHERE id = $1;
23 | -- name: GetNodeByIP :one
24 | SELECT *
25 | FROM nodes
26 | WHERE ip = $1;
27 | -- name: GetNodesWithSysInfo :many
28 | SELECT n.id,
29 | n.name,
30 | n.ip,
31 | nsi.os,
32 | nsi.platform,
33 | nsi.platform_version,
34 | nsi.kernel_version,
35 | nsi.cpus,
36 | nsi.total_memory
37 | FROM nodes as n
38 | JOIN node_sys_info as nsi ON n.id = nsi.node_id
39 | WHERE n.name LIKE '%' || $1 || '%'
40 | OR n.ip LIKE '%' || $1 || '%'
41 | OR nsi.os LIKE '%' || $1 || '%'
42 | OR nsi.platform LIKE '%' || $1 || '%'
43 | OR nsi.platform_version LIKE '%' || $1 || '%'
44 | OR nsi.kernel_version LIKE '%' || $1 || '%'
45 | LIMIT $2 OFFSET $3;
46 |
47 | -- name: GetNodeWithSysInfo :one
48 | SELECT n.id,
49 | n.name,
50 | n.ip,
51 | nsi.os,
52 | nsi.platform,
53 | nsi.platform_version,
54 | nsi.kernel_version,
55 | nsi.cpus,
56 | nsi.total_memory
57 | FROM nodes as n
58 | JOIN node_sys_info as nsi ON n.id = nsi.node_id
59 | WHERE n.id = $1;
60 | -- name: UpdateNodeName :exec
61 | UPDATE nodes
62 | SET name = $2
63 | WHERE id = $1;
64 | --######################################################################################
65 | ------------------------------------sys info-------------------------------------------
66 | -- name: AddNodeSysInfo :one
67 | INSERT INTO node_sys_info (
68 | node_id,
69 | os,
70 | platform,
71 | platform_version,
72 | kernel_version,
73 | cpus,
74 | total_memory
75 | )
76 | VALUES ($1, $2, $3, $4, $5, $6, $7)
77 | RETURNING *;
78 | -- name: GetNodeSysInfoByNodeID :one
79 | SELECT *
80 | FROM node_sys_info
81 | WHERE node_id = $1;
82 | -- name: UpdateNodeSysInfo :one
83 | UPDATE node_sys_info
84 | SET os = $2,
85 | platform = $3,
86 | platform_version = $4,
87 | kernel_version = $5,
88 | cpus = $6,
89 | total_memory = $7,
90 | updated_at = CURRENT_TIMESTAMP
91 | WHERE node_id = $1
92 | RETURNING *;
93 | --######################################################################################
94 | ------------------------------------disk info-------------------------------------------
95 | -- name: AddNodeDiskInfo :one
96 | INSERT INTO node_disk_info (
97 | node_id,
98 | device,
99 | mount_point,
100 | fstype,
101 | total,
102 | used
103 | )
104 | VALUES ($1, $2, $3, $4, $5, $6)
105 | RETURNING *;
106 | -- name: GetNodeDiskInfoByNodeID :many
107 | SELECT *
108 | FROM node_disk_info
109 | WHERE node_id = $1;
110 | -- name: UpdateNodeDiskInfo :one
111 | UPDATE node_disk_info
112 | SET device = $2,
113 | mount_point = $3,
114 | fstype = $4,
115 | total = $5,
116 | used = $6,
117 | updated_at = CURRENT_TIMESTAMP
118 | WHERE node_id = $1
119 | RETURNING *;
--------------------------------------------------------------------------------
/server/internal/db/sql/query/system_stat.sql:
--------------------------------------------------------------------------------
1 | -- name: InsertSystemStats :exec
2 | INSERT INTO system_stats (time, node_id, stat_type, cpu_id, value)
3 | SELECT
4 | unnest($1::timestamptz[]),
5 | unnest($2::int[]),
6 | unnest($3::text[]),
7 | unnest($4::int[]),
8 | unnest($5::double precision[]);
9 |
10 | -- name: GetSystemStats :many
11 | select time,value from system_stats ss
12 | where node_id = $1 and stat_type = $2
13 | and cpu_id = $3
14 | and time >= now() - ($4||'')::interval;
--------------------------------------------------------------------------------
/server/internal/db/sql/query/user.sql:
--------------------------------------------------------------------------------
1 | -- name: CreateUser :one
2 | INSERT INTO users (username, email, password_hash)
3 | VALUES ($1, $2, $3)
4 | RETURNING *;
5 |
6 | -- name: FindUserByEmail :one
7 | SELECT * FROM users WHERE email = $1;
8 |
9 | -- name: FindUserById :one
10 | SELECT * FROM users WHERE id = $1;
--------------------------------------------------------------------------------
/server/internal/db/system_stat.sql.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.28.0
4 | // source: system_stat.sql
5 |
6 | package db
7 |
8 | import (
9 | "context"
10 | "database/sql"
11 | "time"
12 |
13 | "github.com/lib/pq"
14 | )
15 |
16 | const getSystemStats = `-- name: GetSystemStats :many
17 | select time,value from system_stats ss
18 | where node_id = $1 and stat_type = $2
19 | and cpu_id = $3
20 | and time >= now() - ($4||'')::interval
21 | `
22 |
23 | type GetSystemStatsParams struct {
24 | NodeID int32 `json:"node_id"`
25 | StatType string `json:"stat_type"`
26 | CpuID int32 `json:"cpu_id"`
27 | Column4 sql.NullString `json:"column_4"`
28 | }
29 |
30 | type GetSystemStatsRow struct {
31 | Time time.Time `json:"time"`
32 | Value float64 `json:"value"`
33 | }
34 |
35 | func (q *Queries) GetSystemStats(ctx context.Context, arg GetSystemStatsParams) ([]GetSystemStatsRow, error) {
36 | rows, err := q.query(ctx, q.getSystemStatsStmt, getSystemStats,
37 | arg.NodeID,
38 | arg.StatType,
39 | arg.CpuID,
40 | arg.Column4,
41 | )
42 | if err != nil {
43 | return nil, err
44 | }
45 | defer rows.Close()
46 | var items []GetSystemStatsRow
47 | for rows.Next() {
48 | var i GetSystemStatsRow
49 | if err := rows.Scan(&i.Time, &i.Value); err != nil {
50 | return nil, err
51 | }
52 | items = append(items, i)
53 | }
54 | if err := rows.Close(); err != nil {
55 | return nil, err
56 | }
57 | if err := rows.Err(); err != nil {
58 | return nil, err
59 | }
60 | return items, nil
61 | }
62 |
63 | const insertSystemStats = `-- name: InsertSystemStats :exec
64 | INSERT INTO system_stats (time, node_id, stat_type, cpu_id, value)
65 | SELECT
66 | unnest($1::timestamptz[]),
67 | unnest($2::int[]),
68 | unnest($3::text[]),
69 | unnest($4::int[]),
70 | unnest($5::double precision[])
71 | `
72 |
73 | type InsertSystemStatsParams struct {
74 | Column1 []time.Time `json:"column_1"`
75 | Column2 []int32 `json:"column_2"`
76 | Column3 []string `json:"column_3"`
77 | Column4 []int32 `json:"column_4"`
78 | Column5 []float64 `json:"column_5"`
79 | }
80 |
81 | func (q *Queries) InsertSystemStats(ctx context.Context, arg InsertSystemStatsParams) error {
82 | _, err := q.exec(ctx, q.insertSystemStatsStmt, insertSystemStats,
83 | pq.Array(arg.Column1),
84 | pq.Array(arg.Column2),
85 | pq.Array(arg.Column3),
86 | pq.Array(arg.Column4),
87 | pq.Array(arg.Column5),
88 | )
89 | return err
90 | }
91 |
--------------------------------------------------------------------------------
/server/internal/db/user.sql.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.28.0
4 | // source: user.sql
5 |
6 | package db
7 |
8 | import (
9 | "context"
10 | )
11 |
12 | const createUser = `-- name: CreateUser :one
13 | INSERT INTO users (username, email, password_hash)
14 | VALUES ($1, $2, $3)
15 | RETURNING id, username, email, password_hash, created_at, updated_at
16 | `
17 |
18 | type CreateUserParams struct {
19 | Username string `json:"username"`
20 | Email string `json:"email"`
21 | PasswordHash string `json:"password_hash"`
22 | }
23 |
24 | func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
25 | row := q.queryRow(ctx, q.createUserStmt, createUser, arg.Username, arg.Email, arg.PasswordHash)
26 | var i User
27 | err := row.Scan(
28 | &i.ID,
29 | &i.Username,
30 | &i.Email,
31 | &i.PasswordHash,
32 | &i.CreatedAt,
33 | &i.UpdatedAt,
34 | )
35 | return i, err
36 | }
37 |
38 | const findUserByEmail = `-- name: FindUserByEmail :one
39 | SELECT id, username, email, password_hash, created_at, updated_at FROM users WHERE email = $1
40 | `
41 |
42 | func (q *Queries) FindUserByEmail(ctx context.Context, email string) (User, error) {
43 | row := q.queryRow(ctx, q.findUserByEmailStmt, findUserByEmail, email)
44 | var i User
45 | err := row.Scan(
46 | &i.ID,
47 | &i.Username,
48 | &i.Email,
49 | &i.PasswordHash,
50 | &i.CreatedAt,
51 | &i.UpdatedAt,
52 | )
53 | return i, err
54 | }
55 |
56 | const findUserById = `-- name: FindUserById :one
57 | SELECT id, username, email, password_hash, created_at, updated_at FROM users WHERE id = $1
58 | `
59 |
60 | func (q *Queries) FindUserById(ctx context.Context, id int32) (User, error) {
61 | row := q.queryRow(ctx, q.findUserByIdStmt, findUserById, id)
62 | var i User
63 | err := row.Scan(
64 | &i.ID,
65 | &i.Username,
66 | &i.Email,
67 | &i.PasswordHash,
68 | &i.CreatedAt,
69 | &i.UpdatedAt,
70 | )
71 | return i, err
72 | }
73 |
--------------------------------------------------------------------------------
/server/internal/dto/alert_dto.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | type AlertDto struct {
4 | NodeID int32 `json:"node_id" binding:"required"`
5 | Metric string `json:"metric" binding:"required"`
6 | Threshold float64 `json:"threshold"`
7 | NetReceThreshold float64 `json:"net_rece_threshold"`
8 | NetSendThreshold float64 `json:"net_send_threshold"`
9 | Duration int32 `json:"duration"`
10 | Email string `json:"email"`
11 | Discord string `json:"discord"`
12 | Slack string `json:"slack"`
13 | Enabled bool `json:"enabled"`
14 | }
15 |
16 | type AlertUpdateDto struct {
17 | ID int32 `json:"id" binding:"required"`
18 | NodeID int32 `json:"node_id" binding:"required"`
19 | Metric string `json:"metric" binding:"required"`
20 | Threshold float64 `json:"threshold"`
21 | NetReceThreshold float64 `json:"net_rece_threshold"`
22 | NetSendThreshold float64 `json:"net_send_threshold"`
23 | Duration int32 `json:"duration"`
24 | Email string `json:"email"`
25 | Discord string `json:"discord"`
26 | Slack string `json:"slack"`
27 | Enabled bool `json:"enabled"`
28 | }
29 |
30 | // export const AlertSchema = z.object({
31 | // metric: z.enum(["CPU", "Memory", "Network"], { required_error: "Select a metric" }),
32 | // threshold: z.number().min(0).max(100, "Threshold must be between 0-100"),
33 | // duration: z.number().min(1, "Duration must be at least 1 minute"),
34 | // // email: z.string().email().optional(),
35 | // discord: z.string().url().optional(),
36 | // enabled: z.boolean(),
37 | // });
38 |
--------------------------------------------------------------------------------
/server/internal/dto/node_dto.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | import (
4 | "encoding/json"
5 |
6 | "github.com/sanda0/vps_pilot/internal/db"
7 | "github.com/sanda0/vps_pilot/internal/utils"
8 | )
9 |
10 | type NodeWithSysInfoDto struct {
11 | ID int32 `json:"id"`
12 | Name string `json:"name"`
13 | Ip string `json:"ip"`
14 | Os string `json:"os"`
15 | Platform string `json:"platform"`
16 | PlatformVersion string `json:"platform_version"`
17 | KernelVersion string `json:"kernel_version"`
18 | Cpus int32 `json:"cpus"`
19 | TotalMemory float64 `json:"total_memory"`
20 | }
21 |
22 | func (n *NodeWithSysInfoDto) Convert(row *db.GetNodesWithSysInfoRow) {
23 | n.ID = row.ID
24 | n.Name = row.Name.String
25 | n.Ip = row.Ip
26 | n.Os = row.Os.String
27 | n.Platform = row.Platform.String
28 | n.PlatformVersion = row.PlatformVersion.String
29 | n.KernelVersion = row.KernelVersion.String
30 | n.Cpus = row.Cpus.Int32
31 | n.TotalMemory = utils.BytesToGB(row.TotalMemory.Float64)
32 | }
33 |
34 | type NodeNameUpdateDto struct {
35 | NodeId int32 `json:"id"`
36 | Name string `json:"name"`
37 | }
38 |
39 | type NodeDto struct {
40 | ID int32 `json:"id"`
41 | Name string `json:"name"`
42 | Ip string `json:"ip"`
43 | Memory float64 `json:"memory"`
44 | Cpus int32 `json:"cpus"`
45 | }
46 |
47 | type SystemStatQueryDto struct {
48 | Node db.Node `json:"node" `
49 | StatType string `json:"stat_type" binding:"required"`
50 | TimeRange string `json:"time_range" `
51 | }
52 |
53 | type SystemStatResponseDto struct {
54 | NodeID int32 `json:"node_id"`
55 | TimeRange string `json:"time_range"`
56 | Cpu []map[string]interface{} `json:"cpu"`
57 | Mem []db.GetSystemStatsRow `json:"mem"`
58 | Net []db.GetNetStatsRow `json:"net"`
59 | }
60 |
61 | func (s *SystemStatResponseDto) ToBytes() ([]byte, error) {
62 | return json.Marshal(s)
63 | }
64 |
65 | type NodeSystemStatRequestDto struct {
66 | ID int32 `json:"id"`
67 | TimeRange string `json:"time_range"`
68 | }
69 |
70 | func (n *NodeSystemStatRequestDto) FromBytes(data []byte) error {
71 | err := json.Unmarshal(data, n)
72 | if n.TimeRange == "5M" {
73 | n.TimeRange = "5 minutes"
74 | } else if n.TimeRange == "15M" {
75 | n.TimeRange = "15 minutes"
76 | } else if n.TimeRange == "1H" {
77 | n.TimeRange = "1 hour"
78 | } else if n.TimeRange == "1D" {
79 | n.TimeRange = "1 day"
80 | } else if n.TimeRange == "2D" {
81 | n.TimeRange = "2 days"
82 | } else if n.TimeRange == "1W" {
83 | n.TimeRange = "1 week"
84 | }
85 | return err
86 | }
87 |
--------------------------------------------------------------------------------
/server/internal/dto/user_dto.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | type UserLoginDto struct {
4 | Email string `json:"email" binding:"required,email"`
5 | Password string `json:"password" binding:"required"`
6 | }
7 |
8 | type UserLoginResponseDto struct {
9 | ID int32 `json:"id"`
10 | Email string `json:"email"`
11 | Username string `json:"username"`
12 | }
13 |
--------------------------------------------------------------------------------
/server/internal/handlers/alert_handler.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "strconv"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/sanda0/vps_pilot/internal/dto"
8 | "github.com/sanda0/vps_pilot/internal/services"
9 | )
10 |
11 | type AlertHandler interface {
12 | CreateAlert(c *gin.Context)
13 | UpdateAlert(c *gin.Context)
14 | GetAlerts(c *gin.Context)
15 | GetAlert(c *gin.Context)
16 | DeleteAlert(c *gin.Context)
17 | ActivateAlert(c *gin.Context)
18 | DeactivateAlert(c *gin.Context)
19 | }
20 |
21 | type alertHandler struct {
22 | alertService services.AlertService
23 | }
24 |
25 | // ActivateAlert implements AlertHandler.
26 | func (a *alertHandler) ActivateAlert(c *gin.Context) {
27 |
28 | }
29 |
30 | // CreateAlert implements AlertHandler.
31 | func (a *alertHandler) CreateAlert(c *gin.Context) {
32 | form := dto.AlertDto{}
33 | if err := c.ShouldBindJSON(&form); err != nil {
34 | c.JSON(400, gin.H{"error": err.Error()})
35 | return
36 | }
37 | alert, err := a.alertService.CreateAlert(form)
38 | if err != nil {
39 | c.JSON(400, gin.H{"error": err.Error()})
40 | return
41 | }
42 | c.JSON(200, gin.H{"data": alert})
43 | }
44 |
45 | // DeactivateAlert implements AlertHandler.
46 | func (a *alertHandler) DeactivateAlert(c *gin.Context) {
47 | panic("unimplemented")
48 | }
49 |
50 | // DeleteAlert implements AlertHandler.
51 | func (a *alertHandler) DeleteAlert(c *gin.Context) {
52 |
53 | idStr := c.Param("id")
54 | id, err := strconv.Atoi(idStr)
55 | if err != nil {
56 | c.JSON(400, gin.H{"error": err.Error()})
57 | return
58 | }
59 | err = a.alertService.DeleteAlert(int32(id))
60 | if err != nil {
61 | c.JSON(400, gin.H{"error": err.Error()})
62 | return
63 | }
64 | c.JSON(200, gin.H{"data": "Alert deleted"})
65 | }
66 |
67 | // GetAlert implements AlertHandler.
68 | func (a *alertHandler) GetAlert(c *gin.Context) {
69 |
70 | idStr := c.Param("id")
71 | id, err := strconv.Atoi(idStr)
72 | if err != nil {
73 | c.JSON(400, gin.H{"error": err.Error()})
74 | return
75 | }
76 | alert, err := a.alertService.GetAlert(int32(id))
77 | if err != nil {
78 | c.JSON(400, gin.H{"error": err.Error()})
79 | return
80 | }
81 | c.JSON(200, gin.H{"data": alert})
82 |
83 | }
84 |
85 | // GetAlerts implements AlertHandler.
86 | func (a *alertHandler) GetAlerts(c *gin.Context) {
87 |
88 | nodeIdStr := c.Query("node_id")
89 | nodeId, err := strconv.Atoi(nodeIdStr)
90 | if err != nil {
91 | c.JSON(400, gin.H{"error": err.Error()})
92 | return
93 | }
94 | limitStr := c.Query("limit")
95 | limit, err := strconv.Atoi(limitStr)
96 | if err != nil {
97 | c.JSON(400, gin.H{"error": err.Error()})
98 | return
99 | }
100 | offsetStr := c.Query("offset")
101 | offset, err := strconv.Atoi(offsetStr)
102 | if err != nil {
103 | c.JSON(400, gin.H{"error": err.Error()})
104 | return
105 | }
106 | alerts, err := a.alertService.GetAlerts(int32(nodeId), int32(limit), int32(offset))
107 | if err != nil {
108 | c.JSON(400, gin.H{"error": err.Error()})
109 | return
110 | }
111 |
112 | c.JSON(200, gin.H{"data": alerts})
113 | }
114 |
115 | // UpdateAlert implements AlertHandler.
116 | func (a *alertHandler) UpdateAlert(c *gin.Context) {
117 | form := dto.AlertUpdateDto{}
118 | if err := c.ShouldBindJSON(&form); err != nil {
119 | c.JSON(400, gin.H{"error": err.Error()})
120 | return
121 | }
122 | alert, err := a.alertService.UpdateAlert(form)
123 | if err != nil {
124 | c.JSON(400, gin.H{"error": err.Error()})
125 | return
126 | }
127 | c.JSON(200, gin.H{"data": alert})
128 | }
129 |
130 | func NewAlertHandler(alertService services.AlertService) AlertHandler {
131 | return &alertHandler{
132 | alertService: alertService,
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/server/internal/handlers/auth_handler.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/sanda0/vps_pilot/internal/dto"
6 | "github.com/sanda0/vps_pilot/internal/services"
7 | "github.com/sanda0/vps_pilot/internal/utils"
8 | )
9 |
10 | type AuthHandler interface {
11 | Login(c *gin.Context)
12 | Profile(c *gin.Context)
13 | }
14 |
15 | type authHandler struct {
16 | userService services.UserService
17 | }
18 |
19 | // Profile implements AuthHandler.
20 | func (a *authHandler) Profile(c *gin.Context) {
21 | userID, _ := c.Get("user_id")
22 | user, err := a.userService.Profile(userID.(int32))
23 | if err != nil {
24 | c.JSON(400, gin.H{"error": err.Error()})
25 | return
26 | }
27 |
28 | userResponse := dto.UserLoginResponseDto{
29 | ID: user.ID,
30 | Email: user.Email,
31 | Username: user.Username,
32 | }
33 |
34 | c.JSON(200, gin.H{"data": userResponse})
35 | }
36 |
37 | // Login implements AuthHandler.
38 | func (a *authHandler) Login(c *gin.Context) {
39 | form := dto.UserLoginDto{}
40 | if err := c.ShouldBindJSON(&form); err != nil {
41 | utils.WriteTokenToCookie(c, "")
42 | c.JSON(400, gin.H{"error": err.Error()})
43 | return
44 | }
45 |
46 | userResponse, err := a.userService.Login(form)
47 | if err != nil {
48 | utils.WriteTokenToCookie(c, "")
49 | c.JSON(400, gin.H{"error": err.Error()})
50 | return
51 | }
52 |
53 | token, err := utils.GenerateToken(userResponse.ID)
54 | if err != nil {
55 | utils.WriteTokenToCookie(c, "")
56 | c.JSON(400, gin.H{"error": err.Error()})
57 | return
58 | }
59 |
60 | utils.WriteTokenToCookie(c, token)
61 |
62 | c.JSON(200, gin.H{"data": userResponse})
63 |
64 | }
65 |
66 | func NewAuthHandler(userService services.UserService) AuthHandler {
67 | return &authHandler{
68 | userService: userService,
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/server/internal/handlers/node_handler.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 | "strconv"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/gorilla/websocket"
11 | "github.com/sanda0/vps_pilot/internal/dto"
12 | "github.com/sanda0/vps_pilot/internal/services"
13 | )
14 |
15 | type NodeHandler interface {
16 | GetNodes(c *gin.Context)
17 | UpdateName(c *gin.Context)
18 | GetNode(c *gin.Context)
19 | SystemStatWSHandler(c *gin.Context)
20 | }
21 |
22 | type nodeHandler struct {
23 | nodeService services.NodeService
24 | }
25 |
26 | var systemStatUpgrader = websocket.Upgrader{
27 | ReadBufferSize: 1024,
28 | WriteBufferSize: 1024,
29 | CheckOrigin: func(r *http.Request) bool {
30 | return true // Allow all origins (adjust for production)
31 | },
32 | }
33 |
34 | // SystemStatWSHandler implements NodeHandler.
35 | func (n *nodeHandler) SystemStatWSHandler(c *gin.Context) {
36 |
37 | conn, err := systemStatUpgrader.Upgrade(c.Writer, c.Request, nil)
38 | if err != nil {
39 | log.Println(err)
40 | return
41 | }
42 | defer conn.Close()
43 |
44 | // queryParams chan dto.NodeSystemStatRequestDto, result chan dto.SystemStatResponseDto
45 | var queryParams = make(chan dto.NodeSystemStatRequestDto)
46 | var result = make(chan dto.SystemStatResponseDto)
47 | go n.nodeService.GetSystemStat(queryParams, result)
48 | for {
49 | _, message, err := conn.ReadMessage()
50 | if err != nil {
51 | log.Println(err)
52 | return
53 | }
54 | log.Printf("recv: %s", message)
55 |
56 | var query dto.NodeSystemStatRequestDto
57 | query.FromBytes(message)
58 | fmt.Println("Query received", query)
59 | queryParams <- query
60 |
61 | response := <-result
62 |
63 | msg, err := response.ToBytes()
64 | if err != nil {
65 | log.Println(err)
66 | return
67 | }
68 |
69 | err = conn.WriteMessage(websocket.TextMessage, msg)
70 | if err != nil {
71 | log.Println(err)
72 | return
73 | }
74 | }
75 | }
76 |
77 | // GetNode implements NodeHandler.
78 | func (n *nodeHandler) GetNode(c *gin.Context) {
79 | idStr := c.Param("id")
80 | id, err := strconv.Atoi(idStr)
81 | if err != nil {
82 | c.JSON(400, gin.H{"error": err.Error()})
83 | return
84 | }
85 | node, err := n.nodeService.GetNode(int32(id))
86 | if err != nil {
87 | c.JSON(400, gin.H{"error": err.Error()})
88 | return
89 | }
90 | c.JSON(200, gin.H{"data": dto.NodeDto{
91 | ID: node.ID,
92 | Name: node.Name.String,
93 | Ip: node.Ip,
94 | Memory: node.TotalMemory.Float64,
95 | Cpus: node.Cpus.Int32,
96 | }})
97 |
98 | }
99 |
100 | // UpdateName implements NodeHandler.
101 | func (n *nodeHandler) UpdateName(c *gin.Context) {
102 | form := dto.NodeNameUpdateDto{}
103 | if err := c.ShouldBindJSON(&form); err != nil {
104 | c.JSON(400, gin.H{"error": err.Error()})
105 | return
106 | }
107 | err := n.nodeService.UpdateName(form.NodeId, form.Name)
108 | if err != nil {
109 | c.JSON(400, gin.H{"error": err.Error()})
110 | return
111 | }
112 | c.JSON(200, gin.H{"data": "Node name updated"})
113 | }
114 |
115 | // GetNodes implements NodeHandler.
116 | func (n *nodeHandler) GetNodes(c *gin.Context) {
117 | searchQuery := c.Query("search")
118 | pageQuery := c.Query("page")
119 | limitQuery := c.Query("limit")
120 |
121 | page, err := strconv.Atoi(pageQuery)
122 | if err != nil {
123 | page = 1 // default value
124 | }
125 |
126 | limit, err := strconv.Atoi(limitQuery)
127 | if err != nil {
128 | limit = 10 // default value
129 | }
130 |
131 | nodesRows, err := n.nodeService.GetNodesWithSysInfo(searchQuery, int32(limit), int32(page))
132 |
133 | if err != nil {
134 | c.JSON(400, gin.H{"error": err.Error()})
135 | return
136 | }
137 |
138 | nodes := []dto.NodeWithSysInfoDto{}
139 | for _, row := range nodesRows {
140 | node := dto.NodeWithSysInfoDto{}
141 | node.Convert(&row)
142 | nodes = append(nodes, node)
143 | }
144 |
145 | c.JSON(200, gin.H{"data": nodes})
146 |
147 | }
148 |
149 | func NewNodeHandler(nodeService services.NodeService) NodeHandler {
150 | return &nodeHandler{
151 | nodeService: nodeService,
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/server/internal/middleware/auth.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/sanda0/vps_pilot/internal/utils"
8 | )
9 |
10 | func JwtAuthMiddleware() gin.HandlerFunc {
11 | return func(c *gin.Context) {
12 | err := utils.TokenValid(c)
13 | if err != nil {
14 | c.String(http.StatusUnauthorized, "Unauthorized")
15 | c.Abort()
16 | return
17 | }
18 | id, err := utils.ExtractTokenID(c)
19 | _ = id
20 | if err != nil {
21 | c.String(http.StatusUnauthorized, err.Error())
22 | c.Abort()
23 | return
24 | }
25 | c.Set("user_id", id)
26 |
27 | c.Next()
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/internal/middleware/cors.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import "github.com/gin-gonic/gin"
4 |
5 | func CORSMiddleware() gin.HandlerFunc {
6 | return func(c *gin.Context) {
7 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
8 | c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
9 | c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
10 | c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT,DELETE")
11 |
12 | if c.Request.Method == "OPTIONS" {
13 | c.AbortWithStatus(204)
14 | return
15 | }
16 |
17 | c.Next()
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/server/internal/services/alert_service.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 |
7 | "github.com/sanda0/vps_pilot/internal/db"
8 | "github.com/sanda0/vps_pilot/internal/dto"
9 | )
10 |
11 | type AlertService interface {
12 | CreateAlert(dto dto.AlertDto) (*db.Alert, error)
13 | GetAlerts(nodeId int32, limit int32, offset int32) ([]db.Alert, error)
14 | UpdateAlert(dto dto.AlertUpdateDto) (*db.Alert, error)
15 | GetAlert(alertId int32) (*db.Alert, error)
16 | DeleteAlert(alertId int32) error
17 | ActivateAlert(alertId int32) error
18 | DeactivateAlert(alertId int32) error
19 | }
20 |
21 | type alertService struct {
22 | repo *db.Repo
23 | ctx context.Context
24 | }
25 |
26 | // ActivateAlert implements AlertService.
27 | func (a *alertService) ActivateAlert(alertId int32) error {
28 | err := a.repo.Queries.ActivateAlert(a.ctx, alertId)
29 | if err != nil {
30 | return err
31 | }
32 | return nil
33 | }
34 |
35 | // CreateAlert implements AlertService.
36 | func (a *alertService) CreateAlert(dto dto.AlertDto) (*db.Alert, error) {
37 | // metric := "cpu"
38 | // if dto.Metric == "mem" {
39 | // metric = "mem"
40 | // } else if dto.Metric == "net" {
41 | // metric = "net"
42 | // }
43 |
44 | alert, err := a.repo.Queries.CreateAlert(a.ctx, db.CreateAlertParams{
45 | NodeID: dto.NodeID,
46 | Metric: dto.Metric,
47 | Duration: dto.Duration,
48 | Threshold: sql.NullFloat64{
49 | Float64: dto.Threshold,
50 | Valid: true,
51 | },
52 | NetReceThreshold: sql.NullFloat64{
53 | Float64: dto.NetReceThreshold,
54 | Valid: true,
55 | },
56 | NetSendThreshold: sql.NullFloat64{
57 | Float64: dto.NetSendThreshold,
58 | Valid: true,
59 | },
60 | Email: sql.NullString{String: dto.Email, Valid: true},
61 | IsActive: sql.NullBool{
62 | Bool: dto.Enabled,
63 | Valid: true,
64 | },
65 | SlackWebhook: sql.NullString{String: dto.Slack, Valid: true},
66 | DiscordWebhook: sql.NullString{String: dto.Discord, Valid: true},
67 | })
68 | if err != nil {
69 | return nil, err
70 | }
71 | return &alert, nil
72 | }
73 |
74 | // DeactivateAlert implements AlertService.
75 | func (a *alertService) DeactivateAlert(alertId int32) error {
76 | err := a.repo.Queries.DeactivateAlert(a.ctx, alertId)
77 | if err != nil {
78 | return err
79 | }
80 | return nil
81 | }
82 |
83 | // DeleteAlert implements AlertService.
84 | func (a *alertService) DeleteAlert(alertId int32) error {
85 | err := a.repo.Queries.DeleteAlert(a.ctx, alertId)
86 | if err != nil {
87 | return err
88 | }
89 | return nil
90 | }
91 |
92 | // GetAlert implements AlertService.
93 | func (a *alertService) GetAlert(alertId int32) (*db.Alert, error) {
94 | alert, err := a.repo.Queries.GetAlert(a.ctx, alertId)
95 | if err != nil {
96 | return nil, err
97 | }
98 | return &alert, nil
99 | }
100 |
101 | // GetAlerts implements AlertService.
102 | func (a *alertService) GetAlerts(nodeId int32, limit int32, offset int32) ([]db.Alert, error) {
103 | alerts, err := a.repo.Queries.GetAlerts(a.ctx, db.GetAlertsParams{
104 | NodeID: nodeId,
105 | Limit: limit,
106 | Offset: offset,
107 | })
108 |
109 | if err != nil {
110 | return nil, err
111 | }
112 | return alerts, nil
113 | }
114 |
115 | // UpdateAlert implements AlertService.
116 | func (a *alertService) UpdateAlert(dto dto.AlertUpdateDto) (*db.Alert, error) {
117 |
118 | alert, err := a.repo.Queries.UpdateAlert(a.ctx, db.UpdateAlertParams{
119 | ID: dto.ID,
120 | NodeID: dto.NodeID,
121 | Metric: dto.Metric,
122 | Duration: dto.Duration,
123 | Threshold: sql.NullFloat64{
124 | Float64: dto.Threshold,
125 | Valid: true,
126 | },
127 | NetReceThreshold: sql.NullFloat64{
128 | Float64: dto.NetReceThreshold,
129 | Valid: true,
130 | },
131 | NetSendThreshold: sql.NullFloat64{
132 | Float64: dto.NetSendThreshold,
133 | Valid: true,
134 | },
135 | Email: sql.NullString{String: dto.Email, Valid: true},
136 | IsActive: sql.NullBool{
137 | Bool: dto.Enabled,
138 | Valid: true,
139 | },
140 | SlackWebhook: sql.NullString{String: dto.Slack, Valid: true},
141 | DiscordWebhook: sql.NullString{String: dto.Discord, Valid: true},
142 | })
143 | if err != nil {
144 | return nil, err
145 | }
146 | return &alert, nil
147 | }
148 |
149 | func NewAlertService(ctx context.Context, repo *db.Repo) AlertService {
150 | return &alertService{
151 | repo: repo,
152 | ctx: ctx,
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/server/internal/services/node_service.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "fmt"
7 |
8 | "github.com/sanda0/vps_pilot/internal/db"
9 | "github.com/sanda0/vps_pilot/internal/dto"
10 | )
11 |
12 | type NodeService interface {
13 | CreateNode(ip string, data string) error
14 | GetNodesWithSysInfo(search string, limit int32, page int32) ([]db.GetNodesWithSysInfoRow, error)
15 | UpdateName(nodeId int32, name string) error
16 | GetNode(nodeId int32) (db.GetNodeWithSysInfoRow, error)
17 | GetSystemStat(queryParams chan dto.NodeSystemStatRequestDto, result chan dto.SystemStatResponseDto)
18 | }
19 |
20 | type nodeService struct {
21 | repo *db.Repo
22 | ctx context.Context
23 | }
24 |
25 | // GetSystemStat implements NodeService.
26 | func (n *nodeService) GetSystemStat(queryParams chan dto.NodeSystemStatRequestDto, result chan dto.SystemStatResponseDto) {
27 | for query := range queryParams {
28 | fmt.Println("Query received", query)
29 | node, err := n.repo.Queries.GetNodeWithSysInfo(n.ctx, query.ID)
30 | if err != nil {
31 | fmt.Println("Error getting node", err)
32 | continue
33 | }
34 |
35 | memStat, err := n.repo.Queries.GetSystemStats(n.ctx, db.GetSystemStatsParams{
36 | NodeID: query.ID,
37 | StatType: "mem",
38 | Column4: sql.NullString{
39 | String: query.TimeRange,
40 | Valid: true,
41 | },
42 | })
43 | if err != nil {
44 | fmt.Println("Error getting mem stats", err)
45 | continue
46 | }
47 |
48 | cpuStats, err := n.repo.Queries.GetCPUStats(n.ctx, db.GetCPUStatsParams{
49 | NodeID: query.ID,
50 | TimeRange: query.TimeRange,
51 | CpuCount: node.Cpus.Int32,
52 | })
53 |
54 | if err != nil {
55 | fmt.Println("Error getting cpu stats", err)
56 | continue
57 | }
58 |
59 | netStat, err := n.repo.Queries.GetNetStats(n.ctx, db.GetNetStatsParams{
60 | NodeID: query.ID,
61 | Column2: sql.NullString{
62 | String: query.TimeRange,
63 | Valid: true,
64 | },
65 | })
66 |
67 | if err != nil {
68 | fmt.Println("Error getting net stats", err)
69 | continue
70 | }
71 |
72 | result <- dto.SystemStatResponseDto{
73 | NodeID: query.ID,
74 | TimeRange: query.TimeRange,
75 | Cpu: cpuStats,
76 | Mem: memStat,
77 | Net: netStat,
78 | }
79 | }
80 |
81 | fmt.Println("Query processing done")
82 | }
83 |
84 | // GetNode implements NodeService.
85 | func (n *nodeService) GetNode(nodeId int32) (db.GetNodeWithSysInfoRow, error) {
86 | node, err := n.repo.Queries.GetNodeWithSysInfo(n.ctx, nodeId)
87 | if err != nil {
88 | return db.GetNodeWithSysInfoRow{}, err
89 | }
90 | return node, nil
91 | }
92 |
93 | // UpdateName implements NodeService.
94 | func (n *nodeService) UpdateName(nodeId int32, name string) error {
95 | err := n.repo.Queries.UpdateNodeName(n.ctx, db.UpdateNodeNameParams{
96 | ID: nodeId,
97 | Name: sql.NullString{
98 | String: name,
99 | Valid: true,
100 | },
101 | })
102 | if err != nil {
103 | return err
104 | }
105 | return nil
106 | }
107 |
108 | // GetNodesWithSysInfo implements NodeService.
109 | func (n *nodeService) GetNodesWithSysInfo(search string, limit int32, page int32) ([]db.GetNodesWithSysInfoRow, error) {
110 | offset := (page - 1) * limit
111 | nodes, err := n.repo.Queries.GetNodesWithSysInfo(n.ctx, db.GetNodesWithSysInfoParams{
112 | Column1: sql.NullString{
113 | String: search,
114 | Valid: true,
115 | },
116 | Limit: limit,
117 | Offset: offset,
118 | })
119 | fmt.Println(nodes)
120 | if err != nil {
121 | return nil, err
122 | }
123 | return nodes, nil
124 | }
125 |
126 | // CreateNode implements NodeService.
127 | func (n *nodeService) CreateNode(ip string, data string) error {
128 | panic("unimplemented")
129 | }
130 |
131 | func NewNodeService(ctx context.Context, repo *db.Repo) NodeService {
132 | return &nodeService{
133 | repo: repo,
134 | ctx: ctx,
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/server/internal/services/user_service.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/sanda0/vps_pilot/internal/db"
8 | "github.com/sanda0/vps_pilot/internal/dto"
9 | "github.com/sanda0/vps_pilot/internal/utils"
10 | )
11 |
12 | type UserService interface {
13 | Login(form dto.UserLoginDto) (*dto.UserLoginResponseDto, error)
14 | Profile(id int32) (*db.User, error)
15 | }
16 |
17 | type userService struct {
18 | repo *db.Repo
19 | ctx context.Context
20 | }
21 |
22 | // Profile implements UserService.
23 | func (u *userService) Profile(id int32) (*db.User, error) {
24 | user, err := u.repo.Queries.FindUserById(u.ctx, int32(id))
25 | if err != nil {
26 | return nil, err
27 | }
28 |
29 | return &user, nil
30 | }
31 |
32 | // Login implements UserService.
33 | func (u *userService) Login(form dto.UserLoginDto) (*dto.UserLoginResponseDto, error) {
34 | user, err := u.repo.Queries.FindUserByEmail(u.ctx, form.Email)
35 | if err != nil {
36 | return nil, err
37 | }
38 |
39 | if err := utils.VerifyPassword(form.Password, user.PasswordHash); err != nil {
40 | return nil, err
41 |
42 | }
43 |
44 | fmt.Println("user", user)
45 |
46 | response := &dto.UserLoginResponseDto{
47 | ID: user.ID,
48 | Email: user.Email,
49 | Username: user.Username,
50 | }
51 |
52 | return response, nil
53 | }
54 |
55 | func NewUserService(ctx context.Context, repo *db.Repo) UserService {
56 | return &userService{
57 | repo: repo,
58 | ctx: ctx,
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/server/internal/tcpserver/actions.go:
--------------------------------------------------------------------------------
1 | package tcpserver
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "fmt"
7 | "time"
8 |
9 | "github.com/sanda0/vps_pilot/internal/db"
10 | )
11 |
12 | func CreateNode(ctx context.Context, repo *db.Repo, ip string, data []byte) (*db.Node, error) {
13 |
14 | node, err := repo.Queries.GetNodeByIP(ctx, ip)
15 | if err == nil {
16 | return &node, nil
17 | }
18 |
19 | node, err = repo.Queries.CreateNode(ctx, db.CreateNodeParams{
20 | Name: sql.NullString{
21 | String: "node-" + ip,
22 | Valid: true,
23 | },
24 | Ip: ip,
25 | })
26 | if err != nil {
27 | fmt.Println("Error creating node", err)
28 | }
29 | fmt.Println("Node created", node)
30 | sysInfo := SystemInfo{}
31 | err = sysInfo.FromBytes(data)
32 | if err != nil {
33 | fmt.Println("Error unmarshalling system info", err)
34 | }
35 | sysInfoDb, err := repo.Queries.AddNodeSysInfo(ctx, db.AddNodeSysInfoParams{
36 | NodeID: node.ID,
37 | Os: sql.NullString{
38 | String: sysInfo.OS,
39 | Valid: true,
40 | },
41 | Platform: sql.NullString{
42 | String: sysInfo.Platform,
43 | Valid: true,
44 | },
45 | PlatformVersion: sql.NullString{
46 | String: sysInfo.PlatformVersion,
47 | Valid: true,
48 | },
49 | KernelVersion: sql.NullString{
50 | String: sysInfo.KernelVersion,
51 | Valid: true,
52 | },
53 | Cpus: sql.NullInt32{
54 | Int32: int32(sysInfo.CPUs),
55 | Valid: true,
56 | },
57 | TotalMemory: sql.NullFloat64{
58 | Float64: float64(sysInfo.TotalMemory),
59 | Valid: true,
60 | },
61 | })
62 | if err != nil {
63 | fmt.Println("Error adding node sys info", err)
64 | }
65 | fmt.Println("Node sys info added", sysInfoDb)
66 | return &node, nil
67 | }
68 |
69 | func StoreSystemStats(ctx context.Context, repo *db.Repo, statChan chan Msg) {
70 | for msg := range statChan {
71 | sysStat := SystemStat{}
72 | err := sysStat.FromBytes(msg.Data)
73 | if err != nil {
74 | fmt.Println("Error unmarshalling system stat", err)
75 | }
76 | // fmt.Println("Node", msg.NodeId)
77 | // fmt.Println("time", time.Now())
78 |
79 | fmt.Println("Net sent ps", sysStat.NetSentPS, "Net recv ps", sysStat.NetRecvPS)
80 |
81 | var times []time.Time
82 | var nodeIDs []int32
83 | var statTypes []string
84 | var cpuIDs []int32
85 | var values []float64
86 |
87 | for i, cpuUsage := range sysStat.CPUUsage {
88 | times = append(times, time.Now())
89 | nodeIDs = append(nodeIDs, msg.NodeId)
90 | statTypes = append(statTypes, "cpu")
91 | cpuIDs = append(cpuIDs, int32(i+1))
92 | values = append(values, cpuUsage)
93 | }
94 | times = append(times, time.Now())
95 | nodeIDs = append(nodeIDs, msg.NodeId)
96 | statTypes = append(statTypes, "mem")
97 | cpuIDs = append(cpuIDs, 0)
98 | values = append(values, sysStat.MemUsage)
99 |
100 | err = repo.Queries.InsertSystemStats(ctx, db.InsertSystemStatsParams{
101 | Column1: times,
102 | Column2: nodeIDs,
103 | Column3: statTypes,
104 | Column4: cpuIDs,
105 | Column5: values,
106 | })
107 |
108 | if err != nil {
109 | fmt.Println("Error inserting system stats", err)
110 | }
111 |
112 | err = repo.Queries.InsertNetStats(ctx, db.InsertNetStatsParams{
113 | Time: time.Now(),
114 | NodeID: msg.NodeId,
115 | Sent: sysStat.NetSentPS,
116 | Recv: sysStat.NetRecvPS,
117 | })
118 |
119 | if err != nil {
120 | fmt.Println("Error inserting net stats", err)
121 | }
122 |
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/server/internal/tcpserver/alert_sender.go:
--------------------------------------------------------------------------------
1 | package tcpserver
2 |
3 | import (
4 | "bytes"
5 | "crypto/tls"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 | "os"
10 | "strconv"
11 | "time"
12 |
13 | "gopkg.in/gomail.v2"
14 | )
15 |
16 | type AlertMsg struct {
17 | NodeName string
18 | NodeIp string
19 | Metric string
20 | Threshold string
21 | CurrentValue string
22 | Timestamp time.Time
23 | }
24 |
25 | func SendDiscordAlert(webhookURL string, alert AlertMsg) error {
26 | // Format the alert message
27 | message := fmt.Sprintf(
28 | "🚨 **ALERT** 🚨\nNode: `%s`\nIP: `%s`\nMetric: **%s**\nCurrent Value: `%s`\nThreshold: `%s`\nTimestamp: %s",
29 | alert.NodeName, alert.NodeIp, alert.Metric, alert.CurrentValue, alert.Threshold, alert.Timestamp.Format(time.RFC1123),
30 | )
31 |
32 | // Prepare the payload
33 | payload := map[string]string{
34 | "content": message,
35 | }
36 |
37 | // Marshal the payload to JSON
38 | body, err := json.Marshal(payload)
39 | if err != nil {
40 | return err
41 | }
42 |
43 | // Send the POST request to the Discord webhook
44 | resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(body))
45 | if err != nil {
46 | return err
47 | }
48 | defer resp.Body.Close()
49 |
50 | // Check for successful response
51 | if resp.StatusCode >= 200 && resp.StatusCode < 300 {
52 | fmt.Println("Alert sent to Discord!")
53 | } else {
54 | return fmt.Errorf("failed to send alert, status code: %d", resp.StatusCode)
55 | }
56 |
57 | return nil
58 | }
59 |
60 | // SendEmailAlert sends alert notifications via email
61 | func SendEmailAlert(toEmail string, alert AlertMsg) error {
62 | // Get email configuration from environment
63 | host := os.Getenv("MAIL_HOST")
64 | portStr := os.Getenv("MAIL_PORT")
65 | username := os.Getenv("MAIL_USERNAME")
66 | password := os.Getenv("MAIL_PASSWORD")
67 | fromAddress := os.Getenv("MAIL_FROM_ADDRESS")
68 |
69 | if host == "" || portStr == "" || username == "" || password == "" || fromAddress == "" {
70 | return fmt.Errorf("email configuration is incomplete")
71 | }
72 |
73 | port, err := strconv.Atoi(portStr)
74 | if err != nil {
75 | return fmt.Errorf("invalid email port: %v", err)
76 | }
77 |
78 | // Create the email message
79 | m := gomail.NewMessage()
80 | m.SetHeader("From", fromAddress)
81 | m.SetHeader("To", toEmail)
82 | m.SetHeader("Subject", fmt.Sprintf("🚨 VPS Pilot Alert - %s", alert.Metric))
83 |
84 | // HTML email body
85 | body := fmt.Sprintf(`
86 |
87 |
88 | 🚨 ALERT 🚨
89 |
90 |
Node: %s
91 |
IP: %s
92 |
Metric: %s
93 |
Current Value: %s
94 |
Threshold: %s
95 |
Timestamp: %s
96 |
97 | This alert was generated by VPS Pilot monitoring system.
98 |
99 |
100 | `, alert.NodeName, alert.NodeIp, alert.Metric, alert.CurrentValue, alert.Threshold, alert.Timestamp.Format(time.RFC1123))
101 |
102 | m.SetBody("text/html", body)
103 |
104 | // Create dialer
105 | d := gomail.NewDialer(host, port, username, password)
106 | d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
107 |
108 | // Send the email
109 | if err := d.DialAndSend(m); err != nil {
110 | return fmt.Errorf("failed to send email: %v", err)
111 | }
112 |
113 | fmt.Println("Alert sent to email:", toEmail)
114 | return nil
115 | }
116 |
117 | // SendSlackAlert sends alert notifications via Slack webhook
118 | func SendSlackAlert(webhookURL string, alert AlertMsg) error {
119 | // Format the alert message for Slack
120 | message := fmt.Sprintf(
121 | ":rotating_light: *ALERT* :rotating_light:\n*Node:* `%s`\n*IP:* `%s`\n*Metric:* *%s*\n*Current Value:* `%s`\n*Threshold:* `%s`\n*Timestamp:* %s",
122 | alert.NodeName, alert.NodeIp, alert.Metric, alert.CurrentValue, alert.Threshold, alert.Timestamp.Format(time.RFC1123),
123 | )
124 |
125 | // Prepare the Slack payload
126 | payload := map[string]interface{}{
127 | "text": message,
128 | "attachments": []map[string]interface{}{
129 | {
130 | "color": "danger",
131 | "fields": []map[string]interface{}{
132 | {
133 | "title": "Node",
134 | "value": alert.NodeName,
135 | "short": true,
136 | },
137 | {
138 | "title": "IP Address",
139 | "value": alert.NodeIp,
140 | "short": true,
141 | },
142 | {
143 | "title": "Metric",
144 | "value": alert.Metric,
145 | "short": true,
146 | },
147 | {
148 | "title": "Current Value",
149 | "value": alert.CurrentValue,
150 | "short": true,
151 | },
152 | {
153 | "title": "Threshold",
154 | "value": alert.Threshold,
155 | "short": true,
156 | },
157 | {
158 | "title": "Timestamp",
159 | "value": alert.Timestamp.Format(time.RFC1123),
160 | "short": true,
161 | },
162 | },
163 | },
164 | },
165 | }
166 |
167 | // Marshal the payload to JSON
168 | body, err := json.Marshal(payload)
169 | if err != nil {
170 | return err
171 | }
172 |
173 | // Send the POST request to the Slack webhook
174 | resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(body))
175 | if err != nil {
176 | return err
177 | }
178 | defer resp.Body.Close()
179 |
180 | // Check for successful response
181 | if resp.StatusCode >= 200 && resp.StatusCode < 300 {
182 | fmt.Println("Alert sent to Slack!")
183 | } else {
184 | return fmt.Errorf("failed to send Slack alert, status code: %d", resp.StatusCode)
185 | }
186 |
187 | return nil
188 | }
189 |
--------------------------------------------------------------------------------
/server/internal/tcpserver/server.go:
--------------------------------------------------------------------------------
1 | package tcpserver
2 |
3 | import (
4 | "context"
5 | "encoding/gob"
6 | "fmt"
7 | "net"
8 | "strings"
9 |
10 | "github.com/sanda0/vps_pilot/internal/db"
11 | )
12 |
13 | var AgentConnections map[string]net.Conn
14 |
15 | func StartTcpServer(ctx context.Context, repo *db.Repo, port string) {
16 |
17 | var statChan = make(chan Msg, 100)
18 | var monitorChan = make(chan Msg, 100)
19 |
20 | AgentConnections = make(map[string]net.Conn)
21 | listener, err := net.Listen("tcp", ":"+port)
22 | if err != nil {
23 | return
24 | }
25 | defer listener.Close()
26 | fmt.Println("TCP server Listening on port", port)
27 |
28 | go StoreSystemStats(ctx, repo, statChan)
29 | go MontiorAlerts(ctx, repo, monitorChan)
30 |
31 | for {
32 | conn, err := listener.Accept()
33 | if err != nil {
34 | return
35 | }
36 | AgentConnections[conn.RemoteAddr().String()] = conn
37 | go handleRequest(ctx, repo, conn, statChan, monitorChan)
38 | }
39 | }
40 |
41 | func handleRequest(ctx context.Context, repo *db.Repo, conn net.Conn, statChan chan Msg, monitorChan chan Msg) {
42 | defer conn.Close()
43 | fmt.Println("New connection from", conn.RemoteAddr())
44 |
45 | decoder := gob.NewDecoder(conn)
46 | encoder := gob.NewEncoder(conn)
47 | var msg Msg
48 | for {
49 | err := decoder.Decode(&msg)
50 | if err != nil {
51 | break
52 | }
53 | if msg.Msg == "connected" {
54 | ip := strings.Split(conn.RemoteAddr().String(), ":")[0]
55 | node, err := CreateNode(ctx, repo, ip, msg.Data)
56 | if err != nil {
57 | fmt.Println("Error creating node", err)
58 | }
59 | fmt.Println("Node connected", node)
60 | err = encoder.Encode(Msg{
61 | Msg: "sys_stat",
62 | NodeId: node.ID,
63 | })
64 | if err != nil {
65 | fmt.Println("Error encoding message:", err)
66 | }
67 | }
68 | if msg.Msg == "sys_info" {
69 |
70 | fmt.Println("Sys info received", string(msg.Data))
71 | }
72 | if msg.Msg == "sys_stat" {
73 | statChan <- msg
74 | monitorChan <- msg
75 | }
76 | }
77 |
78 | }
79 |
--------------------------------------------------------------------------------
/server/internal/tcpserver/types.go:
--------------------------------------------------------------------------------
1 | package tcpserver
2 |
3 | import "encoding/json"
4 |
5 | type SystemInfo struct {
6 | OS string `json:"os"` // e.g. linux, windows
7 | Platform string `json:"platform"` // e.g. ubuntu, centos
8 | PlatformVersion string `json:"platform_version"` // e.g. 20.04, 8
9 | KernelVersion string `json:"kernel_version"` // e.g. 5.4.0-42-generic
10 | CPUs int `json:"cpus"` // number of CPUs
11 | TotalMemory uint64 `json:"total_memory"` // total memory in bytes
12 | }
13 |
14 | func (s *SystemInfo) FromBytes(data []byte) error {
15 | return json.Unmarshal(data, s)
16 | }
17 |
18 | type Disk struct {
19 | Device string `json:"device"` // e.g. /dev/sda1
20 | Mountpoint string `json:"mountpoint"` // e.g. /
21 | Fstype string `json:"fstype"` // e.g. ext4
22 | Opts string `json:"opts"` // e.g. rw
23 | Total uint64 `json:"total"` // total disk space in bytes
24 | Used uint64 `json:"used"` // used disk space in bytes
25 | }
26 |
27 | type SystemStat struct {
28 | CPUUsage []float64 `json:"cpu_usage"`
29 | MemUsage float64 `json:"mem_usage"`
30 | DiskUsage float64 `json:"disk_usage"`
31 | NetSentPS int64 `json:"net_sent_ps"`
32 | NetRecvPS int64 `json:"net_recv_ps"`
33 | }
34 |
35 | func (s *SystemStat) FromBytes(data []byte) error {
36 | return json.Unmarshal(data, s)
37 | }
38 |
39 | type Msg struct {
40 | Msg string
41 | NodeId int32
42 | Token string
43 | Data []byte
44 | }
45 |
--------------------------------------------------------------------------------
/server/internal/utils/bytecovertor.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | func BytesToKB(bytes float64) float64 {
4 | return float64(bytes) / 1024
5 | }
6 |
7 | func BytesToMB(bytes float64) float64 {
8 | return float64(bytes) / (1024 * 1024)
9 | }
10 |
11 | func BytesToGB(bytes float64) float64 {
12 | return float64(bytes) / (1024 * 1024 * 1024)
13 | }
14 |
--------------------------------------------------------------------------------
/server/internal/utils/password.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "golang.org/x/crypto/bcrypt"
4 |
5 | func HashString(str string) ([]byte, error) {
6 | return bcrypt.GenerateFromPassword([]byte(str), bcrypt.DefaultCost)
7 | }
8 |
9 | func VerifyPassword(password, hashedPassword string) error {
10 | return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
11 | }
12 |
--------------------------------------------------------------------------------
/server/internal/utils/token.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 | "os"
8 | "strconv"
9 | "time"
10 |
11 | "github.com/dgrijalva/jwt-go"
12 | "github.com/gin-gonic/gin"
13 | )
14 |
15 | func ExtractTokenFromHeader(c *gin.Context) string {
16 | // get token from cookie
17 | cookie, err := c.Request.Cookie("__tkn__")
18 |
19 | if err == nil {
20 | return cookie.Value
21 | }
22 |
23 | return ""
24 |
25 | }
26 |
27 | // func ExtractTokenFromHeader(c *gin.Context) string {
28 | // bearerToken := c.Request.Header.Get("Authorization")
29 | // if len(bearerToken) > 7 && bearerToken[:7] == "Bearer " {
30 | // return bearerToken[7:]
31 | // }
32 | // return ""
33 | // }
34 |
35 | func GenerateToken(user_id int32) (string, error) {
36 | token_lifespan, err := strconv.Atoi(os.Getenv("TOKEN_LIFESPAN")) // in minutes
37 | if err != nil {
38 | log.Println("token life span error: ", err)
39 | return "", err
40 | }
41 |
42 | claims := jwt.MapClaims{}
43 | claims["authorized"] = true
44 | claims["user_id"] = user_id
45 | claims["exp"] = time.Now().Add(time.Minute * time.Duration(token_lifespan)).Unix()
46 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
47 |
48 | return token.SignedString([]byte(os.Getenv("TOKEN_SECRET")))
49 |
50 | }
51 |
52 | func TokenValid(c *gin.Context) error {
53 | tokenString := ExtractTokenFromHeader(c)
54 | _, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
55 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
56 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
57 | }
58 | return []byte(os.Getenv("TOKEN_SECRET")), nil
59 | })
60 | if err != nil {
61 |
62 | return err
63 | }
64 | return nil
65 | }
66 |
67 | func ExtractTokenID(c *gin.Context) (int32, error) {
68 |
69 | tokenString := ExtractTokenFromHeader(c)
70 | token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
71 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
72 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
73 | }
74 | return []byte(os.Getenv("TOKEN_SECRET")), nil
75 | })
76 | if err != nil {
77 | return 0, err
78 | }
79 | claims, ok := token.Claims.(jwt.MapClaims)
80 | if ok && token.Valid {
81 | user_id := fmt.Sprintf("%v", claims["user_id"])
82 | userID, err := strconv.ParseUint(user_id, 10, 64)
83 | if err != nil {
84 | return 0, err
85 | }
86 | return int32(userID), nil
87 | }
88 | return 0, nil
89 | }
90 |
91 | func WriteTokenToCookie(c *gin.Context, token string) {
92 | //set http only cookie
93 | cookie := &http.Cookie{
94 | Name: "__tkn__",
95 | Value: token,
96 | HttpOnly: true,
97 | Secure: false, //TODO: change to true in production
98 | Path: "/",
99 | SameSite: http.SameSiteLaxMode,
100 | }
101 | http.SetCookie(c.Writer, cookie)
102 |
103 | }
104 |
--------------------------------------------------------------------------------
/server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "flag"
7 | "fmt"
8 | "log"
9 | "os"
10 |
11 | "github.com/joho/godotenv"
12 | _ "github.com/lib/pq"
13 | "github.com/sanda0/vps_pilot/cmd/app"
14 | "github.com/sanda0/vps_pilot/cmd/cli"
15 | "github.com/sanda0/vps_pilot/internal/db"
16 | "github.com/sanda0/vps_pilot/internal/tcpserver"
17 | )
18 |
19 | func main() {
20 |
21 | err := godotenv.Load(".env")
22 | if err != nil {
23 | log.Fatal("Error loading .env file")
24 | }
25 |
26 | //init db
27 | con, err := sql.Open("postgres", fmt.Sprintf("dbname=%s password=%s user=%s host=%s sslmode=require",
28 | os.Getenv("DB_NAME"),
29 | os.Getenv("DB_PASSWORD"),
30 | os.Getenv("DB_USER"),
31 | os.Getenv("DB_HOST"),
32 | ))
33 |
34 | if err != nil {
35 | panic(err)
36 | }
37 |
38 | //init ctx
39 | ctx := context.Background()
40 | repo := db.NewRepo(con)
41 |
42 | port := flag.String("port", "8080", "port to listen on")
43 | createSuperuser := flag.Bool("create-superuser", false, "create superuser")
44 | createMakefile := flag.Bool("create-makefile", false, "create makefile")
45 | flag.Parse()
46 |
47 | if *createSuperuser {
48 | cli.CreateSuperuser(ctx, repo)
49 | return
50 | }
51 |
52 | if *createMakefile {
53 | err := cli.CreateMakeFile()
54 | if err != nil {
55 | log.Fatal("Error creating Makefile")
56 | }
57 | return
58 | }
59 |
60 | //init tcp server
61 | go tcpserver.StartTcpServer(ctx, repo, "55001")
62 |
63 | app.Run(ctx, repo, *port)
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/server/makefile:
--------------------------------------------------------------------------------
1 |
2 | migration:
3 | @read -p "Enter migration name: " name; \
4 | migrate create -ext sql -dir internal/db/sql/migrations $$name
5 |
6 | migrate:
7 | migrate -source file://internal/db/sql/migrations \
8 | -database postgres://postgres:1234@127.0.0.1:5432/vps_pilot?sslmode=disable up
9 |
10 | rollback:
11 | migrate -source file://internal/db/sql/migrations \
12 | -database postgres://postgres:1234@127.0.0.1:5432/vps_pilot?sslmode=disable down
13 |
14 | drop:
15 | migrate -source file://internal/db/sql/migrations \
16 | -database postgres://postgres:1234@127.0.0.1:5432/vps_pilot?sslmode=disable drop
17 |
18 | sqlc:
19 | sqlc generate
20 |
21 |
22 | migratef:
23 | @read -p "Enter migration number: " num; \
24 | migrate -source file://internal/db/sql/migrations \
25 | -database postgres://postgres:1234@127.0.0.1:5432/vps_pilot?sslmode=disable force $$num
26 |
27 |
--------------------------------------------------------------------------------
/server/sqlc.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | sql:
3 | - engine: "postgresql"
4 | queries: "internal/db/sql/query/"
5 | schema: "internal/db/sql/migrations/"
6 | gen:
7 | go:
8 | package: "db"
9 | out: "internal/db"
10 | emit_json_tags: true
11 | emit_prepared_queries: true
--------------------------------------------------------------------------------
/server/test/custom_query_test.go:
--------------------------------------------------------------------------------
1 | package test
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/sanda0/vps_pilot/internal/db"
8 | )
9 |
10 | func TestGetCPUStats(t *testing.T) {
11 | q := db.Queries{}
12 |
13 | // Test case 1
14 | _, err := q.GetCPUStats(context.Background(), db.GetCPUStatsParams{
15 | NodeID: 1,
16 | TimeRange: "1 day",
17 | CpuCount: 8,
18 | })
19 | if err != nil {
20 | panic(err)
21 | }
22 |
23 | t.Log("GetCPUStats test case 1")
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/tmp/build-errors.log:
--------------------------------------------------------------------------------
1 | exit status 1
--------------------------------------------------------------------------------